feat(commentary): 双 provider 评论 — Angel(Agnes) + 美团大模型(LongCat)

- 新增 articles.commentary_meituan{_status,_model,_error} 4 列 + commentary_engine
- LlmSetting 加 meituan_api_key/base_url/chat_model/interval_sec/enabled/commentary_prompt
- 新 app/services/llm/providers.py 工厂,支持多 provider 客户端
- enrichment 流程改为 commentary_angel + commentary_meituan 并行(asyncio.gather),
  任一 provider 失败不影响另一个
- enrichment_loop 状态判定:任一 provider 状态不是 ok 都视为待 enrich
- alembic 0004_dual_commentary 迁移
- 前端 Feed 卡片 + ArticleDetail 详情页各加一条'美团评论'卡
- AdminLlmSettings 加美团 provider 配置卡(独立 api_key 编辑器,不回显明文)
- LlmSettingOut.meituan_api_key_set (bool) 替代直接回传 key
- 默认 URL https://api.longcat.chat/openai/v1 / 默认模型 LongCat-2.0-Preview
This commit is contained in:
xiaji
2026-06-12 19:00:00 +08:00
parent 3ab6e4c7d0
commit bc36a1fc38
15 changed files with 2746 additions and 48 deletions

View File

@@ -34,8 +34,12 @@ export interface ArticleListItem {
fetched_at: string
image_url?: string | null
// 列表预览钩子(首页展示用,详情页看完整版)
// 双 provider 评论:Angel(原字段)+ 美团(meituan 字段)
commentary?: string | null
commentary_status?: string | null
commentary_meituan?: string | null
commentary_meituan_status?: string | null
commentary_engine?: string | null // angel / meituan / "angel,meituan"
image_ai_url?: string | null
is_starred: boolean
}
@@ -64,8 +68,13 @@ export interface ArticleDetail extends ArticleListItem {
classify_status?: string | null
image_ai_status?: string | null
commentary_status?: string | null
commentary_meituan_status?: string | null
commentary_engine?: string | null
// === LLM 内容 ===
commentary?: string | null
commentary?: string | null // Angel
commentary_meituan?: string | null
commentary_meituan_model?: string | null
commentary_meituan_error?: string | null
entities?: Record<string, any> | null
sentiment?: number | null
duplicate_of?: number | null
@@ -83,6 +92,13 @@ export interface LlmSetting {
enabled: boolean
// 全局屏蔽分类标签;与 sources.blocklist_tags 合并后注入 classify prompt
blocklist_tags?: string[]
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
meituan_api_key_set?: boolean // 不回传 key 真值
meituan_base_url?: string
meituan_chat_model?: string
meituan_interval_sec?: number
meituan_enabled?: boolean
meituan_commentary_prompt?: string | null
updated_at?: string | null
}
@@ -156,6 +172,11 @@ export const adminApi = {
'/admin/llm/settings/test'
).then((r) => r.data)
},
testMeituanConnection() {
return http.post<{ ok: boolean; detail: string; configured: boolean }>(
'/admin/llm/settings/test-meituan'
).then((r) => r.data)
},
triggerEnrich(articleId: number) {
return http.post<{ triggered: boolean; detail: string; results: Record<string, string> | null }>(
`/admin/llm/enrich/${articleId}`

View File

@@ -20,8 +20,31 @@ const setting = ref<LlmSetting>({
interval_sec: 2.0,
enabled: true,
blocklist_tags: [],
// 美团大模型(LongCat,OpenAI 兼容)双 provider 评论
meituan_api_key_set: false,
meituan_base_url: 'https://api.longcat.chat/openai/v1',
meituan_chat_model: 'LongCat-2.0-Preview',
meituan_interval_sec: 2.0,
meituan_enabled: true,
meituan_commentary_prompt: '',
})
// === 美团 api_key 编辑(不回显真值)===
const meituanKeyInput = ref('')
const meituanKeyHasValue = ref(false) // 当前 DB 是否已设置
const meituanKeyPlaceholder = computed(() => {
if (meituanKeyHasValue.value) return '已配置(留空 = 不修改,输入新值 = 覆盖)'
return '请输入 LongCat API Key'
})
function loadMeituanKeyState() {
// 从 setting 的 meituan_api_key_set 推断
meituanKeyHasValue.value = !!setting.value.meituan_api_key_set
meituanKeyInput.value = ''
}
watch(() => setting.value.meituan_api_key_set, loadMeituanKeyState)
// === 屏蔽分类标签(文本框 ↔ 数组) ===
// UI 用逗号分隔,存到 setting.blocklist_tags(数组)
const blocklistText = computed({
@@ -35,11 +58,13 @@ const blocklistText = computed({
})
const testResult = ref<{ ok: boolean; detail: string; configured: boolean } | null>(null)
const meituanTestResult = ref<{ ok: boolean; detail: string; configured: boolean } | null>(null)
async function load() {
loading.value = true
try {
setting.value = await adminApi.getLlmSettings()
loadMeituanKeyState()
} catch (e: any) {
message.error(e?.response?.data?.title || '加载失败')
} finally {
@@ -50,8 +75,20 @@ async function load() {
async function save() {
saving.value = true
try {
const updated = await adminApi.updateLlmSettings(setting.value)
// 美团 api_key:有输入才提交(否则不修改);清空用 "clear" 信号
const body: any = { ...setting.value }
delete body.meituan_api_key_set // 后端不需要这个字段
if (meituanKeyInput.value === '__CLEAR__') {
body.meituan_api_key = ''
} else if (meituanKeyInput.value && meituanKeyInput.value.trim()) {
body.meituan_api_key = meituanKeyInput.value.trim()
} else {
delete body.meituan_api_key
}
const updated = await adminApi.updateLlmSettings(body)
setting.value = updated
meituanKeyInput.value = '' // 重置输入
loadMeituanKeyState()
message.success('已保存')
} catch (e: any) {
message.error(e?.response?.data?.title || '保存失败')
@@ -86,6 +123,21 @@ async function test() {
}
}
async function testMeituan() {
testing.value = true
meituanTestResult.value = null
try {
meituanTestResult.value = await adminApi.testMeituanConnection()
if (meituanTestResult.value.ok) message.success('美团连接 OK')
else message.warning('美团连接失败')
} catch (e: any) {
meituanTestResult.value = { ok: false, detail: e?.message || '请求失败', configured: true }
message.error('测试失败')
} finally {
testing.value = false
}
}
onMounted(load)
</script>
@@ -139,6 +191,73 @@ onMounted(load)
</NSpace>
</NCard>
<NCard title="🐱 美团大模型(LongCat,双 provider 评论)" style="margin-top: 16px">
<NText depth="3" style="font-size: 12px">
Angel(Agnes)并列,各跑各的 prompt,结果存到 articles.commentary_meituan 等字段
留空 api_key = 关闭该 provider,Angel 仍正常工作
</NText>
<NSpace vertical style="margin-top: 12px">
<NSpace align="center">
<NText>启用美团 provider:</NText>
<NSwitch v-model:value="setting.meituan_enabled" />
<NText v-if="!setting.meituan_enabled" depth="3" style="font-size: 12px">(关闭后不再调美团评论)</NText>
</NSpace>
<NSpace align="center">
<NText>API Key:</NText>
<NInput
v-model:value="meituanKeyInput"
type="password"
show-password-on="click"
:placeholder="meituanKeyPlaceholder"
style="width: 360px"
/>
<NButton
v-if="meituanKeyHasValue"
size="small"
type="warning"
ghost
@click="meituanKeyInput = '__CLEAR__'"
>
清空
</NButton>
<NText v-if="meituanKeyHasValue" depth="3" style="font-size: 12px; color: #18a058"> 已配置</NText>
<NText v-else depth="3" style="font-size: 12px; color: #d03050"> 未配置</NText>
</NSpace>
<NSpace>
<NText>Base URL:</NText>
<NInput v-model:value="setting.meituan_base_url" placeholder="https://api.longcat.chat/openai/v1" style="width: 380px" />
</NSpace>
<NSpace>
<NText>Chat 模型:</NText>
<NInput v-model:value="setting.meituan_chat_model" placeholder="LongCat-2.0-Preview" style="width: 240px" />
</NSpace>
<NSpace>
<NText>调用间隔():</NText>
<NInputNumber v-model:value="setting.meituan_interval_sec" :min="0" :max="60" :step="0.5" />
<NText depth="3" style="font-size: 12px">(chat 串行,每次调用后等这么久)</NText>
</NSpace>
<NSpace>
<NButton :loading="testing" @click="testMeituan">测美团连接</NButton>
<NText v-if="meituanTestResult" :type="meituanTestResult.ok ? 'success' : 'warning'" style="font-size: 12px">
{{ meituanTestResult.detail }}
</NText>
</NSpace>
</NSpace>
<NDivider style="margin: 12px 0" />
<NText depth="3" style="font-size: 12px">
美团点评 prompt 模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{body}</NCode> = 译文正文留空用默认
</NText>
<NInput
v-model:value="setting.meituan_commentary_prompt"
type="textarea"
:autosize="{ minRows: 4, maxRows: 12 }"
placeholder="留空用默认"
style="margin-top: 8px"
/>
</NCard>
<NCard title="排版提示词" style="margin-top: 16px">
<NText depth="3" style="font-size: 12px">
模板变量: <NCode>{body}</NCode> = 译文正文

View File

@@ -245,7 +245,10 @@ onMounted(load)
插图:{{ article.image_ai_status || 'n/a' }}
</NTag>
<NTag size="tiny" :type="statusTagType(article.commentary_status)" :bordered="false" round>
点评:{{ article.commentary_status || 'n/a' }}
点评(Angel):{{ article.commentary_status || 'n/a' }}
</NTag>
<NTag size="tiny" :type="statusTagType(article.commentary_meituan_status)" :bordered="false" round>
点评(美团):{{ article.commentary_meituan_status || 'n/a' }}
</NTag>
</NSpace>
</NSpace>
@@ -255,10 +258,10 @@ onMounted(load)
本条翻译失败,可点 "重译" 重试,或查看后端日志
</NAlert>
<!-- 1) 评论(LLM 点评) -->
<!-- 1) 评论( provider:Angel + 美团,各自一张卡) -->
<NCard v-if="article.commentary" class="detail-card" style="margin-top: 16px">
<template #header>
<span class="card-header-title">💬 评论</span>
<span class="card-header-title">💬 Angel 评论</span>
</template>
<template #header-extra>
<NTag size="tiny" :type="statusTagType(article.commentary_status)" :bordered="false" round>
@@ -268,6 +271,31 @@ onMounted(load)
<p class="commentary-text-detail">{{ article.commentary }}</p>
</NCard>
<NCard v-if="article.commentary_meituan" class="detail-card" style="margin-top: 16px">
<template #header>
<span class="card-header-title commentary-header-meituan">🐱 美团评论</span>
</template>
<template #header-extra>
<NTag size="tiny" :type="statusTagType(article.commentary_meituan_status)" :bordered="false" round>
{{ article.commentary_meituan_status || 'n/a' }}
</NTag>
</template>
<p class="commentary-text-detail">{{ article.commentary_meituan }}</p>
<NText v-if="article.commentary_meituan_model" :depth="3" style="font-size: 11px; display:block; margin-top:8px">
模型: {{ article.commentary_meituan_model }}
</NText>
</NCard>
<NAlert
v-else-if="article.commentary_meituan_status === 'failed' && article.commentary_meituan_error"
type="warning"
style="margin-top: 16px"
:show-icon="false"
>
<div><strong>美团评论生成失败</strong></div>
<div style="font-size: 12px; margin-top: 4px">{{ article.commentary_meituan_error }}</div>
</NAlert>
<!-- 2) 译文(优先 LLM 排版版) -->
<div v-if="showTranslation" style="margin-top: 16px">
<NCard v-if="article.body_zh_formatted" class="detail-card">
@@ -339,6 +367,10 @@ onMounted(load)
color: var(--color-letter);
}
.commentary-header-meituan {
color: #c2410c; /* 橙色,与 Angel header 区分 */
}
.commentary-text-detail {
white-space: pre-wrap;
line-height: 1.85;

View File

@@ -224,20 +224,36 @@ onMounted(async () => {
{{ bodyExcerpt(a.body_zh_text || a.summary_zh, 220) }}
</div>
<!-- 评论钩子(淡木色背景 + 木色左边框, Android 对齐) -->
<!-- 评论钩子( provider:Angel + 美团,淡木色背景 + 木色左边框, Android 对齐) -->
<div
v-if="a.commentary"
v-if="a.commentary || a.commentary_meituan"
class="commentary-box"
>
<NSpace align="center" :size="6" style="margin-bottom: 6px">
<span class="commentary-label">💬 评论</span>
<NTag size="tiny" :type="commentaryStatusType(a.commentary_status)" round :bordered="false">
{{ a.commentary_status || 'n/a' }}
</NTag>
</NSpace>
<div class="commentary-text">
{{ previewCommentary(a.commentary, 140) }}
</div>
<!-- Angel 评论 -->
<template v-if="a.commentary">
<NSpace align="center" :size="6" style="margin-bottom: 6px">
<span class="commentary-label">💬 Angel 评论</span>
<NTag size="tiny" :type="commentaryStatusType(a.commentary_status)" round :bordered="false">
{{ a.commentary_status || 'n/a' }}
</NTag>
</NSpace>
<div class="commentary-text">
{{ previewCommentary(a.commentary, 140) }}
</div>
</template>
<!-- 美团评论 -->
<template v-if="a.commentary_meituan">
<div v-if="a.commentary" class="commentary-divider" />
<NSpace align="center" :size="6" style="margin-bottom: 6px">
<span class="commentary-label commentary-label-meituan">🐱 美团评论</span>
<NTag size="tiny" :type="commentaryStatusType(a.commentary_meituan_status)" round :bordered="false">
{{ a.commentary_meituan_status || 'n/a' }}
</NTag>
</NSpace>
<div class="commentary-text">
{{ previewCommentary(a.commentary_meituan, 140) }}
</div>
</template>
</div>
</NSpace>
</NCard>
@@ -277,12 +293,23 @@ onMounted(async () => {
color: var(--color-primary);
}
.commentary-label-meituan {
color: #c2410c; /* 橙色,与 Angel 区分 */
}
.commentary-text {
color: var(--color-letter);
font-size: 13px;
line-height: 1.7;
}
.commentary-divider {
height: 1px;
background: var(--color-primary-soft);
margin: 10px 0;
opacity: 0.6;
}
/* ===== 桌面端默认宽度 ===== */
.feed-source-select {
min-width: 240px;