feat(ui): 新增 LLM 智能增强设置页 + 路由/侧栏 + ArticleDetail 展示排版/分类/插图/点评
This commit is contained in:
@@ -44,15 +44,36 @@ export interface ArticleDetail extends ArticleListItem {
|
||||
body_text: string
|
||||
body_zh_html?: string | null
|
||||
body_zh_text?: string | null
|
||||
body_zh_formatted?: string | null // LLM 排版后 HTML
|
||||
author?: string | null
|
||||
image_ai_url?: string | null // LLM 生成的插图
|
||||
translation_engine?: string | null
|
||||
translated_at?: string | null
|
||||
// === LLM 增强状态 ===
|
||||
format_status?: string | null
|
||||
classify_status?: string | null
|
||||
image_ai_status?: string | null
|
||||
commentary_status?: string | null
|
||||
// === LLM 内容 ===
|
||||
commentary?: string | null
|
||||
entities?: Record<string, any> | null
|
||||
sentiment?: number | null
|
||||
duplicate_of?: number | null
|
||||
}
|
||||
|
||||
export interface LlmSetting {
|
||||
format_prompt?: string | null
|
||||
classify_prompt?: string | null
|
||||
commentary_prompt?: string | null
|
||||
image_prompt_template?: string | null
|
||||
image_size: string
|
||||
chat_model: string
|
||||
image_model: string
|
||||
interval_sec: number
|
||||
enabled: boolean
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export const articlesApi = {
|
||||
list(params: Record<string, any> = {}) {
|
||||
return http.get<ArticleListResponse>('/articles', { params }).then((r) => r.data)
|
||||
@@ -108,4 +129,24 @@ export const adminApi = {
|
||||
health() {
|
||||
return http.get('/admin/health').then((r) => r.data)
|
||||
},
|
||||
// === LLM 设置 ===
|
||||
getLlmSettings() {
|
||||
return http.get<LlmSetting>('/admin/llm/settings').then((r) => r.data)
|
||||
},
|
||||
updateLlmSettings(body: Partial<LlmSetting>) {
|
||||
return http.put<LlmSetting>('/admin/llm/settings', body).then((r) => r.data)
|
||||
},
|
||||
resetLlmSettings() {
|
||||
return http.post<{ reset: boolean; detail: string }>('/admin/llm/settings/reset').then((r) => r.data)
|
||||
},
|
||||
testLlmConnection() {
|
||||
return http.post<{ ok: boolean; detail: string; configured: boolean }>(
|
||||
'/admin/llm/settings/test'
|
||||
).then((r) => r.data)
|
||||
},
|
||||
triggerEnrich(articleId: number) {
|
||||
return http.post<{ triggered: boolean; detail: string; results: Record<string, string> | null }>(
|
||||
`/admin/llm/enrich/${articleId}`
|
||||
).then((r) => r.data)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -20,7 +20,10 @@ const menu = computed(() => [
|
||||
{ key: '/', label: '24h 列表', icon: () => '📰' },
|
||||
{ key: '/sources', label: '采集源', icon: () => '📡' },
|
||||
{ key: '/bookmarks', label: '收藏', icon: () => '⭐' },
|
||||
...(auth.isOwner ? [{ key: '/admin/sources', label: '源管理(Admin)', icon: () => '🛠' }] : []),
|
||||
...(auth.isOwner ? [
|
||||
{ key: '/admin/sources', label: '源管理(Admin)', icon: () => '🛠' },
|
||||
{ key: '/admin/llm', label: 'LLM 智能增强', icon: () => '🤖' },
|
||||
] : []),
|
||||
])
|
||||
|
||||
const userMenu = computed(() => [
|
||||
|
||||
@@ -13,6 +13,7 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'sources', component: () => import('@/views/Sources.vue') },
|
||||
{ path: 'bookmarks', component: () => import('@/views/Bookmarks.vue') },
|
||||
{ path: 'admin/sources', component: () => import('@/views/AdminSources.vue'), meta: { ownerOnly: true } },
|
||||
{ path: 'admin/llm', component: () => import('@/views/AdminLlmSettings.vue'), meta: { ownerOnly: true } },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
183
frontend/src/views/AdminLlmSettings.vue
Normal file
183
frontend/src/views/AdminLlmSettings.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import {
|
||||
NCard, NSpace, NButton, NInput, NInputNumber, NSwitch, NAlert, useMessage, NSpin, NDivider, NText, NCode,
|
||||
} from 'naive-ui'
|
||||
import { adminApi, type LlmSetting } from '@/api/articles'
|
||||
|
||||
const message = useMessage()
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const setting = ref<LlmSetting>({
|
||||
format_prompt: '',
|
||||
classify_prompt: '',
|
||||
commentary_prompt: '',
|
||||
image_prompt_template: '',
|
||||
image_size: '1024x768',
|
||||
chat_model: 'agnes-2.0-flash',
|
||||
image_model: 'agnes-image-2.1-flash',
|
||||
interval_sec: 2.0,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const testResult = ref<{ ok: boolean; detail: string; configured: boolean } | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
setting.value = await adminApi.getLlmSettings()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updated = await adminApi.updateLlmSettings(setting.value)
|
||||
setting.value = updated
|
||||
message.success('已保存')
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reset() {
|
||||
if (!confirm('恢复所有提示词为默认值?(模型名 / 限速 / enabled 保留)')) return
|
||||
try {
|
||||
const out = await adminApi.resetLlmSettings()
|
||||
message.success(out.detail || '已重置')
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
testResult.value = await adminApi.testLlmConnection()
|
||||
if (testResult.value.ok) message.success('连接 OK')
|
||||
else message.warning('连接失败')
|
||||
} catch (e: any) {
|
||||
testResult.value = { ok: false, detail: e?.message || '请求失败', configured: true }
|
||||
message.error('测试失败')
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NSpace vertical>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<div>
|
||||
<h2 style="margin: 0">🤖 LLM 智能增强设置</h2>
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
翻译完成后,自动调用 Agnes LLM 跑 4 项任务:排版 / 分类 / 插图 / 点评
|
||||
</NText>
|
||||
</div>
|
||||
<NSpace>
|
||||
<NButton :loading="testing" @click="test">测连接</NButton>
|
||||
<NButton @click="reset">重置默认提示词</NButton>
|
||||
<NButton type="primary" :loading="saving" :disabled="loading" @click="save">保存</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
|
||||
<NAlert v-if="testResult" :type="testResult.ok ? 'success' : 'warning'" :show-icon="false">
|
||||
<NText strong>测连接结果:</NText>
|
||||
<NText>{{ testResult.detail }}</NText>
|
||||
</NAlert>
|
||||
|
||||
<NSpin :show="loading">
|
||||
<NCard title="总开关 + 模型">
|
||||
<NSpace vertical>
|
||||
<NSpace align="center">
|
||||
<NText>启用 LLM 增强:</NText>
|
||||
<NSwitch v-model:value="setting.enabled" />
|
||||
<NText v-if="!setting.enabled" depth="3" style="font-size: 12px">(关闭后翻译后不再调 LLM)</NText>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>文生文模型:</NText>
|
||||
<NInput v-model:value="setting.chat_model" placeholder="agnes-2.0-flash" style="width: 240px" />
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>文生图模型:</NText>
|
||||
<NInput v-model:value="setting.image_model" placeholder="agnes-image-2.1-flash" style="width: 240px" />
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>插图尺寸:</NText>
|
||||
<NInput v-model:value="setting.image_size" placeholder="1024x768" style="width: 160px" />
|
||||
<NText depth="3" style="font-size: 12px">(格式: WIDTHxHEIGHT,如 1024x768)</NText>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText>LLM 调用间隔(秒):</NText>
|
||||
<NInputNumber v-model:value="setting.interval_sec" :min="0" :max="60" :step="0.5" />
|
||||
<NText depth="3" style="font-size: 12px">(chat + image 各 1 个串行,每次调用后等这么久)</NText>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
<NCard title="排版提示词" style="margin-top: 16px">
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
模板变量: <NCode>{body}</NCode> = 译文正文
|
||||
</NText>
|
||||
<NInput
|
||||
v-model:value="setting.format_prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 6, maxRows: 20 }"
|
||||
placeholder="留空用默认"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</NCard>
|
||||
|
||||
<NCard title="分类提示词" style="margin-top: 16px">
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
模板变量: <NCode>{title}</NCode> = 译后标题, <NCode>{summary}</NCode> = 摘要。<br />
|
||||
期望返回 JSON,形如 <NCode>{`{"categories": ["时政", "国际"]}`}</NCode>
|
||||
</NText>
|
||||
<NInput
|
||||
v-model:value="setting.classify_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>{title}</NCode> = 译后标题, <NCode>{body}</NCode> = 译文正文
|
||||
</NText>
|
||||
<NInput
|
||||
v-model:value="setting.commentary_prompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 4, maxRows: 12 }"
|
||||
placeholder="留空用默认"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</NCard>
|
||||
|
||||
<NCard title="插图 prompt 模板" style="margin-top: 16px">
|
||||
<NText depth="3" style="font-size: 12px">
|
||||
模板变量: <NCode>{title}</NCode> = 标题(优先译后)。最终 prompt 会拼成英文描述发给文生图模型
|
||||
</NText>
|
||||
<NInput
|
||||
v-model:value="setting.image_prompt_template"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
placeholder="留空用默认"
|
||||
style="margin-top: 8px"
|
||||
/>
|
||||
</NCard>
|
||||
</NSpin>
|
||||
</NSpace>
|
||||
</template>
|
||||
@@ -2,9 +2,9 @@
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, NDivider, NAlert, NSkeleton, useMessage,
|
||||
NCard, NSpace, NTag, NText, NButton, NSpin, NEmpty, NDivider, NAlert, NSkeleton, NImage, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { articlesApi, type ArticleDetail } from '@/api/articles'
|
||||
import { articlesApi, adminApi, type ArticleDetail } from '@/api/articles'
|
||||
import { http } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -21,6 +21,8 @@ const loading = ref(true)
|
||||
const starred = ref(false)
|
||||
const showOriginal = ref(true)
|
||||
const showTranslation = ref(true)
|
||||
const showFormatted = ref(true)
|
||||
const enriching = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -60,6 +62,7 @@ function fmtTime(s?: string | null) {
|
||||
|
||||
const publishedAt = computed(() => article.value?.published_at || article.value?.fetched_at)
|
||||
const isOwner = computed(() => auth.isOwner)
|
||||
const categories = computed(() => (article.value?.category || '').split(',').filter(Boolean))
|
||||
|
||||
async function rerunTranslation() {
|
||||
if (!article.value) return
|
||||
@@ -72,6 +75,30 @@ async function rerunTranslation() {
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerEnrich() {
|
||||
if (!article.value) return
|
||||
enriching.value = true
|
||||
try {
|
||||
const out = await adminApi.triggerEnrich(article.value.id)
|
||||
const r = out.results || {}
|
||||
const ok = Object.values(r).filter((v) => v === 'ok').length
|
||||
const fail = Object.values(r).filter((v) => v.startsWith('failed')).length
|
||||
message.info(`完成 ${ok} 项,失败 ${fail} 项`)
|
||||
await load()
|
||||
} catch (e: any) {
|
||||
message.error(e?.response?.data?.title || '触发失败')
|
||||
} finally {
|
||||
enriching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusTagType(s?: string | null): 'success' | 'warning' | 'error' | 'default' {
|
||||
if (s === 'ok') return 'success'
|
||||
if (s === 'failed') return 'error'
|
||||
if (s === 'pending') return 'warning'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -93,6 +120,7 @@ onMounted(load)
|
||||
<NTag v-if="article.translation_engine" size="small">
|
||||
{{ article.translation_engine }}
|
||||
</NTag>
|
||||
<NTag v-for="c in categories" :key="c" type="success" size="small">{{ c }}</NTag>
|
||||
</NSpace>
|
||||
<h1 style="margin: 0">{{ article.title }}</h1>
|
||||
<h2 v-if="article.title_zh" style="margin: 0; color: #2080f0">{{ article.title_zh }}</h2>
|
||||
@@ -109,8 +137,18 @@ onMounted(load)
|
||||
<NButton v-if="isOwner" type="error" ghost @click="rerunTranslation">
|
||||
重译
|
||||
</NButton>
|
||||
<NButton v-if="isOwner" type="info" ghost :loading="enriching" @click="triggerEnrich">
|
||||
跑 LLM 增强
|
||||
</NButton>
|
||||
<NButton tag="a" :href="article.url" target="_blank" rel="noopener">原文链接 ↗</NButton>
|
||||
</NSpace>
|
||||
<NSpace v-if="isOwner" size="small" align="center">
|
||||
<NText depth="3" style="font-size: 12px">LLM 状态:</NText>
|
||||
<NTag size="tiny" :type="statusTagType(article.format_status)">排版:{{ article.format_status || 'n/a' }}</NTag>
|
||||
<NTag size="tiny" :type="statusTagType(article.classify_status)">分类:{{ article.classify_status || 'n/a' }}</NTag>
|
||||
<NTag size="tiny" :type="statusTagType(article.image_ai_status)">插图:{{ article.image_ai_status || 'n/a' }}</NTag>
|
||||
<NTag size="tiny" :type="statusTagType(article.commentary_status)">点评:{{ article.commentary_status || 'n/a' }}</NTag>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NCard>
|
||||
|
||||
@@ -118,15 +156,25 @@ onMounted(load)
|
||||
本条翻译失败,可点 "重译" 重试,或查看后端日志。
|
||||
</NAlert>
|
||||
|
||||
<div v-if="showOriginal" style="margin-top: 16px">
|
||||
<NCard title="原文">
|
||||
<div v-if="article.body_html" v-html="article.body_html" style="line-height: 1.8" />
|
||||
<div v-else style="white-space: pre-wrap; line-height: 1.8">{{ article.body_text }}</div>
|
||||
<div v-if="article.image_ai_url" style="margin-top: 16px">
|
||||
<NCard title="🎨 AI 插图">
|
||||
<NImage :src="article.image_ai_url" object-fit="cover" style="max-width: 100%; border-radius: 6px" />
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<div v-if="showTranslation" style="margin-top: 16px">
|
||||
<NCard title="译文">
|
||||
<NCard v-if="article.body_zh_formatted" title="译文(LLM 排版版)">
|
||||
<template #header-extra>
|
||||
<NSpace align="center">
|
||||
<NTag size="tiny" :type="statusTagType(article.format_status)">{{ article.format_status || 'n/a' }}</NTag>
|
||||
<NButton text size="tiny" @click="showFormatted = !showFormatted">
|
||||
{{ showFormatted ? '隐藏' : '显示' }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</template>
|
||||
<div v-if="showFormatted" v-html="article.body_zh_formatted" style="line-height: 1.8" />
|
||||
</NCard>
|
||||
<NCard v-else title="译文(原始)" style="margin-top: 16px">
|
||||
<div v-if="article.body_zh_html" v-html="article.body_zh_html" style="line-height: 1.8" />
|
||||
<div v-else-if="article.body_zh_text" style="white-space: pre-wrap; line-height: 1.8">
|
||||
{{ article.body_zh_text }}
|
||||
@@ -135,15 +183,22 @@ onMounted(load)
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<NCard v-if="article.commentary || article.entities" style="margin-top: 16px" title="智能增强 (预留)">
|
||||
<div v-if="article.commentary">
|
||||
<NText strong>点评: </NText>
|
||||
<span>{{ article.commentary }}</span>
|
||||
</div>
|
||||
<div v-if="article.entities" style="margin-top: 8px">
|
||||
<NText strong>实体: </NText>
|
||||
<code>{{ JSON.stringify(article.entities) }}</code>
|
||||
</div>
|
||||
<div v-if="showOriginal" style="margin-top: 16px">
|
||||
<NCard title="原文">
|
||||
<div v-if="article.body_html" v-html="article.body_html" style="line-height: 1.8" />
|
||||
<div v-else style="white-space: pre-wrap; line-height: 1.8">{{ article.body_text }}</div>
|
||||
</NCard>
|
||||
</div>
|
||||
|
||||
<NCard v-if="article.commentary" style="margin-top: 16px" title="💬 AI 点评">
|
||||
<template #header-extra>
|
||||
<NTag size="tiny" :type="statusTagType(article.commentary_status)">{{ article.commentary_status || 'n/a' }}</NTag>
|
||||
</template>
|
||||
<p style="white-space: pre-wrap; line-height: 1.8; margin: 0">{{ article.commentary }}</p>
|
||||
</NCard>
|
||||
|
||||
<NCard v-if="article.entities" style="margin-top: 16px" title="🔍 实体(预留)">
|
||||
<code style="font-size: 12px">{{ JSON.stringify(article.entities) }}</code>
|
||||
</NCard>
|
||||
</div>
|
||||
</NSpin>
|
||||
|
||||
Reference in New Issue
Block a user