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:
@@ -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}`
|
||||
|
||||
@@ -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> = 译文正文
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user