feat: 微信公众号技能包重命名(weixin_public_account)并增强功能

- 将 wechat_official_account 重命名为 weixin_public_account,符合中文命名规范
- 新增 DOCX 文档生成技能、聊天历史、请求 ID 中间件
- 增强工作流、热点服务、文章服务等模块功能
- 前端同步重命名组件和 API
- 新增架构文档 AR13/AR14、专员文档更新
- 补充测试用例(seed_specialists_test, db_migration_test)

Co-Authored-AI: yes
This commit is contained in:
eaiadmin
2026-09-24 21:13:19 +08:00
parent 593323a934
commit 89ae31c998
60 changed files with 7251 additions and 967 deletions
+28 -2
View File
@@ -1,5 +1,15 @@
import http from './http'
// 会话持久化:列出当前用户的 AI 对话会话
export function listChatConversations() {
return http.get('/ai-chat/conversations')
}
// 会话持久化:获取某个会话的历史消息
export function getChatConversationMessages(conversationId) {
return http.get(`/ai-chat/conversations/${conversationId}/messages`)
}
export function quickActions() {
return http.get('/ai-chat/quick-actions')
}
@@ -32,20 +42,36 @@ export function getSecretsStatus() {
return http.get('/ai/secrets-status')
}
// 管理员:AI 通路测试 —— 获取某路由的候选模型清单(配置 model ∪ 自动发现)
export function getRouteTestModels(routeId) {
return http.post('/ai/route-test/models', { route_id: routeId })
}
// 管理员:AI 通路测试 —— 对单个模型或候选模型逐个发起真实测试
// models 为空 → 只测配置 model;填多个 → 逐个试跑
export function runRouteTest(params) {
return http.post('/ai/route-test/run', params || {})
}
// SSE 流式对话:后端 text/event-stream,每行 "data: {json}\n\n"
export async function streamChat(message, context, history, handlers) {
// conversationId 可选:传入后请求体会携带 conversation_id,用于会话持久化
export async function streamChat(message, context, history, handlers, conversationId = '') {
const token = localStorage.getItem('token')
const onChunk = typeof handlers === 'function' ? handlers : handlers?.onChunk
const onMeta = typeof handlers === 'function' ? null : handlers?.onMeta
const onDone = typeof handlers === 'function' ? null : handlers?.onDone
const aiRouteId = typeof handlers === 'function' ? '' : (handlers?.aiRouteId || '')
const payload = { message, context, history, ai_route_id: aiRouteId }
if (conversationId) {
payload.conversation_id = conversationId
}
const resp = await fetch('/api/ai-chat/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ message, context, history, ai_route_id: aiRouteId }),
body: JSON.stringify(payload),
})
if (!resp.ok) {
@@ -1,33 +0,0 @@
import http from './http'
export function createOfficialAccountTask(data) {
return http.post('/official-account/tasks', data)
}
export function getOfficialAccountWorkflow(taskId) {
return http.get(`/official-account/tasks/${taskId}/workflow`)
}
export function updateOfficialAccountTask(taskId, data) {
return http.put(`/official-account/tasks/${taskId}`, data)
}
export function executeOfficialAccountWorkflowStep(taskId, stepKey, data) {
return http.post(`/official-account/tasks/${taskId}/steps/${stepKey}`, data, {
timeout: 0,
})
}
export function regenerateOfficialAccountImage(taskId, imageKey) {
return http.post(`/official-account/tasks/${taskId}/images/${imageKey}/regenerate`, {}, {
timeout: 0,
})
}
export function exportOfficialAccountDocument(taskId, format) {
return http.get(`/official-account/tasks/${taskId}/export`, {
params: { format },
responseType: 'blob',
timeout: 0,
})
}
@@ -0,0 +1,47 @@
import http from './http'
export function createWeixinPublicAccountTask(data) {
return http.post('/weixin-public-account/tasks', data)
}
export function getWeixinPublicAccountWorkflow(taskId) {
return http.get(`/weixin-public-account/tasks/${taskId}/workflow`)
}
export function updateWeixinPublicAccountTask(taskId, data) {
return http.put(`/weixin-public-account/tasks/${taskId}`, data)
}
export function executeWeixinPublicAccountWorkflowStep(taskId, stepKey, data) {
return http.post(`/weixin-public-account/tasks/${taskId}/steps/${stepKey}`, data, {
timeout: 0,
})
}
export function regenerateWeixinPublicAccountImage(taskId, imageKey) {
return http.post(`/weixin-public-account/tasks/${taskId}/images/${imageKey}/regenerate`, {}, {
timeout: 0,
})
}
// 生成图接口挂在 Auth 中间件后面(只认 Authorization 头),<img src> 发不出这个头,
// 所以图片必须走 axios 取成 blob,再用 createObjectURL 交给 <img>。
// 传进来的 url 是后端给的绝对路径(/api/weixin-public-account/generated-images/xxx.png),
// http 的 baseURL 已经是 /api,这里要剥掉前缀再发,否则会变成 /api/api/...
export function fetchWeixinPublicAccountGeneratedImage(url) {
const path = String(url || '').trim()
if (!path) return Promise.reject(new Error('图片地址为空'))
const relative = path.startsWith('/api/') ? path.slice('/api'.length) : path
return http.get(relative, {
responseType: 'blob',
timeout: 0,
})
}
export function exportWeixinPublicAccountDocument(taskId, format) {
return http.get(`/weixin-public-account/tasks/${taskId}/export`, {
params: { format },
responseType: 'blob',
timeout: 0,
})
}
@@ -79,7 +79,7 @@ const visible = computed(() => Boolean(kind.value))
const label = computed(() => {
if (kind.value === 'specialist') {
if (isGeneralAssistant.value) return '通用专员'
return currentSpecialist.value?.label || specialistEntry.value?.label || '已选专员'
return specialistEntry.value?.label || currentSpecialist.value?.label || '已选专员'
}
return skillEntry.value?.label || ''
})
@@ -89,7 +89,9 @@ const badge = computed(() => (kind.value === 'specialist' ? '专' : '技'))
const hint = computed(() => {
if (kind.value === 'specialist') {
if (isGeneralAssistant.value) return '当前专员:通用专员'
return specialistEntry.value ? `当前专员:${label.value} · 点开它的工作台` : `当前专员:${label.value}`
return specialistEntry.value?.objectEntryRoute
? `当前专员:${label.value} · 点开它的工作台`
: `当前专员:${label.value}`
}
return skillEntry.value ? `当前技能:${label.value}` : label.value
})
@@ -11,8 +11,8 @@
靠推断(见 utils/specialistFlow.js 的 markFlowState),并且推断出来的那一步
在界面上要标出来,不能让它冒充实测进度。
-->
<OfficialAccountSpecialistPanel
v-if="isOfficialAccountSpecialist"
<WeixinPublicAccountSpecialistPanel
v-if="isWeixinPublicAccountSpecialist"
@collapse="emit('collapse')"
/>
<div v-else class="specialist-panel">
@@ -162,7 +162,7 @@ import { storeToRefs } from 'pinia'
import { DEFAULT_SKILL_KEY, GENERAL_ASSISTANT_KEY, useTaskRuntimeStore } from '@/store/taskRuntime'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
import { artifactsOfStep, buildActionRecords, buildResultRecords, markFlowState } from '@/specialists/runtime/specialistFlow'
import OfficialAccountSpecialistPanel from '@/components/chat/OfficialAccountSpecialistPanel.vue'
import WeixinPublicAccountSpecialistPanel from '@/components/chat/WeixinPublicAccountSpecialistPanel.vue'
const emit = defineEmits(['collapse'])
@@ -181,7 +181,7 @@ const {
const tab = ref('flow')
const specialist = computed(() => currentSpecialist.value)
const isOfficialAccountSpecialist = computed(() => String(currentSpecialistKey.value || '').trim() === 'wechat-official-account')
const isWeixinPublicAccountSpecialist = computed(() => String(currentSpecialistKey.value || '').trim() === 'weixin-public-account')
const skill = computed(() =>
skillCatalog.getByKey(currentFocusedObjectKey.value)
)
@@ -79,7 +79,7 @@ export const projectTemplates = [
icon: EditPen,
prompt:
'本项目的内容一律先出提纲再成稿,成稿后必须过一遍校对。语气保持克制,不堆形容词,不写没有出处的数据。每篇发布后补一条复盘:阅读表现、读者反馈、下次要改什么。',
specialistKeys: ['wechat-official-account'],
specialistKeys: ['weixin-public-account'],
skillKeys: ['copy-proofreading'],
}),
]
@@ -3,7 +3,7 @@ import { createRegistry } from '@/specialists/core/registry'
import generalAssistantManifest from '@/specialists/packages/general-assistant/manifest'
import contractReviewManifest from '@/specialists/packages/contract-review/manifest'
import solutionProposalManifest from '@/specialists/packages/solution-proposal/manifest'
import wechatOfficialAccountManifest from '@/specialists/packages/wechat-official-account/manifest'
import wechatWeixinPublicAccountManifest from '@/specialists/packages/weixin-public-account/manifest'
import customerFollowupManifest from '@/specialists/packages/customer-followup/manifest'
import resumeProcessorManifest from '@/specialists/packages/resume-processor/manifest'
@@ -11,7 +11,7 @@ const builtinSpecialistRegistry = createRegistry([
generalAssistantManifest,
contractReviewManifest,
solutionProposalManifest,
wechatOfficialAccountManifest,
wechatWeixinPublicAccountManifest,
customerFollowupManifest,
resumeProcessorManifest,
])
@@ -1,26 +0,0 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'wechat-official-account',
label: '公众号创作专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '热点选题、标题、提纲、正文四步创作工作流',
status: '4 个节点可执行',
risk: '0 个异常',
color: '#2563eb',
stage: '正文创作',
progress: 0,
interactionCard: {
greeting: '我是公众号创作专员,可以帮你把选题、提纲、正文和发布准备串起来。',
tagline: '把内容创作流程从想法推进到可发布。',
openingPrompt: '告诉我主题、受众和语气,我先帮你起草内容结构。',
relationshipToUser: '你的内容创作协作搭档',
starterPrompts: [
'围绕这个热点起 3 个公众号选题',
'帮我写一版文章提纲和开头',
'把这篇草稿润成适合公众号发布的正文',
],
},
})
@@ -0,0 +1,30 @@
import { createSpecialistManifest } from '@/specialists/contracts/manifest'
export default createSpecialistManifest({
key: 'weixin-public-account',
label: '公众号创作专员',
tier: 'generic',
specialistMode: 'dw',
marketTag: '已安装',
summary: '热点选题、标题、提纲、正文、配图到分页 MD,八步创作工作流',
status: '8 个节点可执行',
risk: '0 个异常',
color: '#2563eb',
stage: '正文创作',
progress: 0,
interactionCard: {
greeting:
'我是公众号创作专员,专职从热点里找选题、拟定标题、搭好提纲、写好正文,一路推进到可发布。你只要告诉我主题、受众和语气,我就接手后续的内容创作流程。',
// 卡片上显示的就是这一行(manifest 的交互卡压过后端那份),
// 与 summary / 后端 interaction_card_json.tagline 保持同一句:同一件事只能有一种说法。
tagline: '热点选题、标题、提纲、正文、配图到分页 MD,八步创作工作流',
openingPrompt: '告诉我主题、目标受众和语气,我先帮你起草选题、标题和内容结构,再逐步写出正文。',
relationshipToUser: '你的公众号内容创作搭档',
starterPrompts: [
'以最近这个热点起 3 个公众号选题',
'为这个选题起草标题和文章提纲',
'按我的受众和风格写一版微信公众号正文',
'把这篇草稿润色成适合公众号发布的口吻',
],
},
})
@@ -16,6 +16,40 @@ function safeParseObject(value, fallback = {}) {
}
}
// 后端 interaction_card_json 采用 snake_case,前端展示契约采用 camelCase。
// 这里把后端的 snake_case 键归一化到 camelCase,方便两种数据源按同一契约合并。
function normalizeInteractionCardKeys(card = {}) {
const mapping = {
name: 'name',
tagline: 'tagline',
greeting: 'greeting',
relationship_to_user: 'relationshipToUser',
tone: 'tone',
opening_prompt: 'openingPrompt',
starter_prompts: 'starterPrompts',
boundaries: 'boundaries',
}
const normalized = {}
for (const [key, value] of Object.entries(card || {})) {
const target = mapping[key] || key
normalized[target] = Array.isArray(value) ? value.slice() : value
}
return normalized
}
// 合并前后端互动卡:前端 manifest 是权威介绍来源,后端只在 manifest 未定义字段时兜底。
function mergeInteractionCards(manifestCard = {}, remoteCard = {}) {
const manifest = normalizeInteractionCardKeys(manifestCard)
const remote = normalizeInteractionCardKeys(remoteCard)
const merged = { ...remote }
for (const key of Object.keys(manifest)) {
if (manifest[key] !== undefined && manifest[key] !== null && manifest[key] !== '') {
merged[key] = manifest[key]
}
}
return merged
}
function normalizeSpecialist(item) {
const interactionCard = safeParseObject(item?.interaction_card_json, {})
return {
@@ -79,10 +113,7 @@ function mergeSpecialists(remoteItems = []) {
return applySystemSpecialistConvention({
...fallback,
...normalized,
interactionCard:
Object.keys(normalized.interactionCard || {}).length
? normalized.interactionCard
: (fallback.interactionCard || {}),
interactionCard: mergeInteractionCards(fallback.interactionCard, normalized.interactionCard),
}, fallback)
})
@@ -49,6 +49,7 @@
<div class="route-selector-section">
<div class="section-label">Agent → 路由映射</div>
<el-form label-width="150px" class="cfg-form">
<div class="route-group-label">对话 / 文本</div>
<el-form-item label="PathCoach 对话">
<el-select v-model="aiConfig.agent_routes.path_coach" placeholder="选择对话路由" style="width: 100%">
<el-option
@@ -59,7 +60,7 @@
/>
</el-select>
</el-form-item>
<el-form-item label="快捷动作">
<el-form-item label="快捷动作 / 标题">
<el-select v-model="aiConfig.agent_routes.title_gen" placeholder="选择对话路由" style="width: 100%">
<el-option
v-for="aiRoute in aiChatRoutes"
@@ -69,6 +70,8 @@
/>
</el-select>
</el-form-item>
<div class="route-group-label">向量</div>
<el-form-item label="Embedding">
<el-select v-model="aiConfig.agent_routes.embed_gen" placeholder="选择 Embedding 路由" style="width: 100%">
<el-option
@@ -79,6 +82,36 @@
/>
</el-select>
</el-form-item>
<div class="route-group-label">图片</div>
<el-form-item label="图片生成">
<el-select v-model="aiConfig.agent_routes.image_gen" placeholder="选择图片生成路由" style="width: 100%">
<el-option
v-for="imgRoute in imageRouteOptions"
:key="imgRoute.ai_route_id"
:label="formatAiRouteLabel(imgRoute)"
:value="imgRoute.ai_route_id"
/>
</el-select>
</el-form-item>
<div class="route-group-label">视频</div>
<el-form-item label="视频生成">
<el-select v-model="aiConfig.agent_routes.video_gen" placeholder="选择视频生成路由" style="width: 100%">
<el-option
v-for="vdRoute in videoRouteOptions"
:key="vdRoute.ai_route_id"
:label="formatAiRouteLabel(vdRoute)"
:value="vdRoute.ai_route_id"
/>
</el-select>
</el-form-item>
</el-form>
</div>
<div class="route-selector-section">
<div class="section-label">默认路由(未显式映射时兜底)</div>
<el-form label-width="150px" class="cfg-form">
<el-form-item label="默认对话路由">
<el-select v-model="aiConfig.default_route" placeholder="选择默认对话路由" style="width: 100%">
<el-option
@@ -238,6 +271,168 @@
</el-table>
</el-card>
</el-tab-pane>
<el-tab-pane label="6.4.3 通路测试" name="routetest">
<div v-loading="testLoading">
<el-alert
title="通路测试用于对新路由 / 新模型做真实业务验收:对每个候选模型发起一次真实请求并记录原始报文。本测试不扣算力点、不写入调用日志,仅作后台诊断。"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px;"
/>
<el-card shadow="never" style="margin-bottom: 16px;">
<div class="filter-row">
<span class="filter-label">路由类型</span>
<el-radio-group v-model="testKind" @change="onTestKindChange">
<el-radio-button value="chat">对话</el-radio-button>
<el-radio-button value="embed">Embedding</el-radio-button>
<el-radio-button value="image">生图</el-radio-button>
</el-radio-group>
<span class="filter-label">路由</span>
<el-select
v-model="selectedRouteId"
placeholder="选择要测试的路由"
style="width: 320px"
class="test-route-select"
@change="onTestRouteChange"
>
<el-option
v-for="rt in testRouteOptions"
:key="rt.ai_route_id"
:label="formatAiRouteLabel(rt)"
:value="rt.ai_route_id"
/>
</el-select>
</div>
<div v-if="selectedRoute" class="route-test-panel">
<div class="section-label">候选模型清单(配置 model ∪ 自动发现 /models)</div>
<div class="route-test-meta">
<el-tag size="small" effect="plain">{{ selectedRoute.provider || '-' }}</el-tag>
<span class="route-test-meta-text">{{ selectedRoute.base_url || '' }}{{ selectedRoute.endpoint || '' }}</span>
</div>
<div class="test-actions">
<el-input
v-model="testPrompt"
type="textarea"
:rows="2"
placeholder="业务测试提示词(留空使用默认测试词)"
style="flex: 1"
/>
<el-input
v-if="testKind === 'image'"
v-model="testSize"
placeholder="尺寸 如 1024x1024"
style="width: 160px"
/>
<el-button type="primary" :loading="testingAll" @click="testAllModels">
全部逐个测试
</el-button>
</div>
<div v-if="candidates.length === 0" class="cfg-desc">切换路由后加载候选模型清单。</div>
<el-table :data="pagedCandidates" border stripe style="margin-top: 12px;">
<el-table-column label="模型" min-width="220">
<template #default="{ row }">{{ row.model }}</template>
</el-table-column>
<el-table-column label="配置/发现" width="110">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="row.from_config ? 'primary' : 'info'">
{{ row.from_config ? '配置' : '自动发现' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag v-if="row.status === 'ok'" type="success" size="small">成功</el-tag>
<el-tag v-else-if="row.status === 'fail'" type="danger" size="small">失败</el-tag>
<span v-else class="cfg-desc">未测</span>
</template>
</el-table-column>
<el-table-column label="耗时" width="90">
<template #default="{ row }">{{ row.latency_ms != null ? row.latency_ms + ' ms' : '-' }}</template>
</el-table-column>
<el-table-column label="HTTP" width="80">
<template #default="{ row }">{{ row.status_code || '-' }}</template>
</el-table-column>
<el-table-column label="结果摘要" min-width="200">
<template #default="{ row }">
<span v-if="row.error" class="tone-danger">{{ row.error }}</span>
<span v-else>{{ row.summary || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="产物" min-width="140">
<template #default="{ row }">
<el-image
v-if="testKind === 'image' && row.output_preview && row.output_preview.startsWith('data:')"
:src="row.output_preview"
:preview-src-list="[row.output_preview]"
fit="cover"
style="width: 64px; height: 64px; border-radius: 4px;"
/>
<span v-else class="cfg-desc">-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<template #default="{ row }">
<el-button size="small" :loading="row.testing" @click="testSingleModel(row)">单独测试</el-button>
<el-button
size="small"
type="success"
plain
:disabled="row.status !== 'ok'"
title="把测试通过的模型设为当前路由的 model"
@click="applyModel(row)"
>
设为可用
</el-button>
</template>
</el-table-column>
</el-table>
<div class="test-pagination">
<span class="cfg-desc" style="line-height: 32px;">共 {{ candidates.length }} 个模型</span>
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="candidates.length"
:page-sizes="[10, 20, 50]"
layout="sizes, prev, pager, next"
background
@size-change="onTestPageSizeChange"
/>
</div>
<div v-if="selectedRouteId" class="cfg-desc" style="margin-top: 8px;">
{{ candidates.some((r) => r.status === 'ok') ? '选中测试成功的模型,点击「设为可用」写入该路由 model,随后到左侧「保存 AI 配置」热生效。' : '先对模型执行测试,测试成功后即可「设为可用」。' }}
</div>
<el-divider v-if="candidates.some((r) => r.raw_response || r.raw_request)" content-position="left">
原始报文(可诊断)
</el-divider>
<el-collapse v-if="candidates.some((r) => r.raw_response || r.raw_request)" class="json-collapse">
<el-collapse-item
v-for="row in candidates.filter((r) => r.raw_response || r.raw_request)"
:key="row.model"
:title="`${row.model} ${row.status === 'ok' ? '✅' : row.status === 'fail' ? '❌' : ''}`"
>
<template v-if="row.raw_request">
<div class="cfg-desc">Request</div>
<pre class="raw-pre">{{ row.raw_request }}</pre>
</template>
<div v-if="row.raw_response">
<div class="cfg-desc" style="margin-top: 8px;">Response</div>
<pre class="raw-pre">{{ row.raw_response }}</pre>
</div>
</el-collapse-item>
</el-collapse>
</div>
</el-card>
</div>
</el-tab-pane>
</el-tabs>
</section>
</div>
@@ -246,7 +441,7 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAiConfig, getAiUsage, getAiUsageUsers, getSecretsStatus, putAiConfig, reloadAiConfig } from '@/api/ai'
import { getAiConfig, getAiUsage, getAiUsageUsers, getRouteTestModels, getSecretsStatus, putAiConfig, reloadAiConfig, runRouteTest } from '@/api/ai'
import { listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import DonutChart from '@/components/charts/DonutChart.vue'
import LineChart from '@/components/charts/LineChart.vue'
@@ -276,7 +471,32 @@ const userRows = ref([])
const dayBuckets = ref([])
const usageKindBuckets = ref([])
// —— 6.4.3 通路测试 ——
const testLoading = ref(false)
const testingAll = ref(false)
const testKind = ref('chat')
const testRouteOptions = ref([])
const selectedRouteId = ref('')
const selectedRoute = ref(null)
const candidates = ref([])
const testPrompt = ref('')
const testSize = ref('1024x1024')
const currentPage = ref(1)
const pageSize = ref(10)
const providerCount = computed(() => Object.keys(secrets.value || {}).length)
const imageRouteOptions = computed(() => imageTestRoutesFromConfig())
const videoRouteOptions = computed(() => routesByCategory('video_routes'))
const totalPages = computed(() => Math.max(1, Math.ceil(candidates.value.length / pageSize.value)))
const pagedCandidates = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return candidates.value.slice(start, start + pageSize.value)
})
function onTestPageSizeChange() {
currentPage.value = 1
}
const groupLabel = computed(() => ({ month: '月份', usage_kind: '类型', provider: 'Provider' }[groupBy.value] || '分组'))
const dailyItems = computed(() => dayBuckets.value.map((item) => ({ label: item.bucket, value: item.total_calls })))
const successSegments = computed(() => [
@@ -323,6 +543,7 @@ async function loadRoutes() {
aiEmbedRoutes.value = []
} finally {
routeLoading.value = false
syncTestRoutes()
}
}
@@ -390,6 +611,166 @@ async function loadUsers() {
}
}
// —— 6.4.3 通路测试 / 6.4.1 分类路由下拉 ——
// 按配置分类键(image_routes / video_routes 等)解析路由列表
function routesByCategory(key) {
try {
const parsed = JSON.parse(aiConfigText.value)
const routes = parsed[key] || {}
return Object.entries(routes).map(([id, info]) => ({
ai_route_id: id,
provider: info?.provider || '',
model: info?.model || '',
base_url: info?.base_url || '',
endpoint: info?.endpoint || '',
description: info?.description || '',
}))
} catch (e) {
return []
}
}
function imageTestRoutesFromConfig() {
return routesByCategory('image_routes')
}
function onTestKindChange() {
selectedRouteId.value = ''
selectedRoute.value = null
candidates.value = []
currentPage.value = 1
pageSize.value = 10
if (testKind.value === 'chat') {
testRouteOptions.value = aiChatRoutes.value || []
} else if (testKind.value === 'embed') {
testRouteOptions.value = aiEmbedRoutes.value || []
} else {
testRouteOptions.value = imageTestRoutesFromConfig()
}
}
async function onTestRouteChange(routeId) {
selectedRoute.value = testRouteOptions.value.find((r) => r.ai_route_id === routeId) || null
candidates.value = []
currentPage.value = 1
if (!routeId) return
testLoading.value = true
try {
const res = await getRouteTestModels(routeId)
const data = res.data || {}
if (selectedRoute.value) {
selectedRoute.value.provider = data.provider || selectedRoute.value.provider
selectedRoute.value.base_url = data.base_url || selectedRoute.value.base_url
selectedRoute.value.endpoint = data.endpoint || selectedRoute.value.endpoint
}
const configModel = data.config_model
const list = data.candidates || []
const configSet = new Set(configModel ? [configModel] : [])
;(data.discovered || []).forEach((m) => { if (!configSet.has(m)) configSet.add(m) })
candidates.value = list.map((model) => ({
model,
from_config: model === configModel,
status: '',
status_code: null,
latency_ms: null,
summary: '',
error: '',
output_preview: '',
raw_request: '',
raw_response: '',
testing: false,
}))
} catch (e) {
ElMessage.error(e?.message || '加载候选模型失败')
candidates.value = []
} finally {
testLoading.value = false
}
}
async function runSingle(routeId, model) {
return runRouteTest({ route_id: routeId, models: [model], prompt: testPrompt.value || undefined, size: testKind.value === 'image' ? testSize.value : undefined })
}
function applyResult(row, result) {
const item = result?.items?.[0]
if (!item) return
row.status = item.ok ? 'ok' : 'fail'
row.status_code = item.status_code
row.latency_ms = item.latency_ms
row.error = item.error || ''
row.summary = item.summary || ''
row.output_preview = item.output_preview || ''
row.raw_request = item.raw_request || ''
row.raw_response = item.raw_response || ''
}
async function testSingleModel(row) {
if (!selectedRouteId.value) return
row.testing = true
row.status = ''
row.error = ''
row.status_code = null
row.latency_ms = null
try {
const res = await runSingle(selectedRouteId.value, row.model)
applyResult(row, res.data?.result)
if (row.status !== 'ok') ElMessage.error(`${row.model} 测试失败:${row.error || '未知错误'}`)
} catch (e) {
row.status = 'fail'
row.error = e?.message || '测试请求失败'
} finally {
row.testing = false
}
}
async function testAllModels() {
if (!selectedRouteId.value || candidates.value.length === 0) return
testingAll.value = true
try {
const models = candidates.value.map((r) => r.model)
const res = await runRouteTest({ route_id: selectedRouteId.value, models, prompt: testPrompt.value || undefined, size: testKind.value === 'image' ? testSize.value : undefined })
const result = res.data?.result
const items = result?.items || []
candidates.value.forEach((row, index) => {
if (items[index]) applyResult(row, { items: [items[index]] })
})
const failed = candidates.value.filter((r) => r.status === 'fail')
if (failed.length > 0) ElMessage.warning(`${failed.length} 个模型测试失败`)
else ElMessage.success('全部模型测试通过')
} catch (e) {
ElMessage.error(e?.message || '批量测试失败')
} finally {
testingAll.value = false
}
}
// 把测试成功的模型写回当前路由的 model 字段(改 aiConfigText,再由「保存AI配置」生效)
function applyModel(row) {
if (!selectedRouteId.value || !row || row.status !== 'ok') return
try {
const cfg = JSON.parse(aiConfigText.value)
const key = testKind.value === 'image' ? 'image_routes' : testKind.value === 'embed' ? 'embed_routes' : 'chat_routes'
const routes = cfg[key]
if (!routes || !routes[selectedRouteId.value] || Array.isArray(routes[selectedRouteId.value])) {
ElMessage.error('未在配置中找到该路由,无法写回 model')
return
}
routes[selectedRouteId.value].model = row.model
aiConfigText.value = JSON.stringify(cfg, null, 2)
if (selectedRoute.value) selectedRoute.value.model = row.model
ElMessage.success(`已写入 ${selectedRouteId.value} 的 model = ${row.model},请点「保存 AI 配置」热生效`)
} catch (e) {
ElMessage.error('写回 model 失败:' + (e?.message || ''))
}
}
// 同步通路测试路由选项(加载 chat/embed 路由后调用)
function syncTestRoutes() {
if (testKind.value === 'chat') testRouteOptions.value = aiChatRoutes.value || []
else if (testKind.value === 'embed') testRouteOptions.value = aiEmbedRoutes.value || []
}
async function loadCharts() {
try {
const [dayRes, usageKindRes] = await Promise.all([
@@ -414,6 +795,7 @@ onMounted(() => {
loadUsage()
loadUsers()
loadCharts()
syncTestRoutes()
})
</script>
@@ -510,4 +892,64 @@ onMounted(() => {
.tone-primary {
color: #409eff;
}
.test-route-select :deep(.el-select__wrapper) {
min-height: 32px;
}
.route-test-panel {
margin-top: 16px;
}
.route-test-meta {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
margin-bottom: 8px;
}
.route-test-meta-text {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
color: #909399;
}
.test-actions {
display: flex;
gap: 12px;
align-items: flex-start;
margin-top: 12px;
}
.route-group-label {
font-size: 12px;
font-weight: 600;
color: #909399;
margin: 4px 0 0 0;
padding: 4px 0 0 0;
}
.test-pagination {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
}
.raw-pre {
background: #f7f8fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 8px 10px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -194,7 +194,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { quickAction, quickActions, streamChat } from '@/api/ai'
import { quickAction, quickActions, streamChat, listChatConversations, getChatConversationMessages } from '@/api/ai'
import { listKnowledgeSpaces } from '@/api/knowledge'
import LlmRoutePicker from '@/components/chat/LlmRoutePicker.vue'
import { useAiRouteStore } from '@/store/llmRoute'
@@ -221,6 +221,8 @@ const followupPrompts = [
const sessions = ref([])
const activeSessionId = ref('')
// 记录已拉取过历史消息的会话,避免重复请求
const loadedConversations = new Set()
const selectedSpaceId = ref('all')
const composer = ref('')
const attachments = ref([])
@@ -267,6 +269,7 @@ function createSession() {
const id = `session-${Date.now()}`
sessions.value.unshift({
id,
conversationId: id,
title: '新会话',
updatedAt: formatNow(),
messages: [],
@@ -363,7 +366,7 @@ async function submitMessage() {
onDone() {
citationLoading.value = false
},
})
}, activeSession.value?.conversationId)
} catch (error) {
assistantMessage.content = error.message || '知识库助手暂时不可用,请稍后重试。'
citationLoading.value = false
@@ -443,8 +446,63 @@ function formatNow() {
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
}
onMounted(async () => {
async function loadConversations() {
try {
const res = await listChatConversations()
const items = res?.data?.items || res?.items || []
if (Array.isArray(items) && items.length) {
const mapped = items.map((item) => ({
id: item.conversationId || item.id || item.conversation_id || `session-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
conversationId: item.conversationId || item.id || item.conversation_id,
title: item.title || '历史会话',
lastContent: item.last_content || item.lastContent || '',
messageCount: item.message_count ?? item.messageCount ?? 0,
updatedAt: formatNow(),
messages: [],
fromServer: true,
}))
// 按 conversationId 去重合并
const seen = new Set()
const merged = []
for (const item of mapped) {
if (item.conversationId && seen.has(item.conversationId)) continue
if (item.conversationId) seen.add(item.conversationId)
merged.push(item)
}
sessions.value = merged
if (merged.length) {
activeSessionId.value = merged[0].id
return
}
}
} catch (error) {
// 后端异常时静默降级为新建空会话
}
createSession()
}
async function loadSessionMessages(session) {
if (!session?.conversationId || loadedConversations.has(session.conversationId)) return
loadedConversations.add(session.conversationId)
try {
const res = await getChatConversationMessages(session.conversationId)
const messages = res?.data?.messages || res?.messages || []
if (Array.isArray(messages)) {
session.messages = messages.map((m) => ({
id: m.id || `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
role: m.role === 'assistant' ? 'assistant' : 'user',
content: m.content || '',
meta: m.meta || {},
}))
}
} catch (error) {
// 拉取失败静默降级为空消息
session.messages = []
}
}
onMounted(async () => {
await loadConversations()
aiRouteStore.ensureAiChatRoutes()
try {
const spaceRes = await listKnowledgeSpaces()
@@ -478,6 +536,19 @@ onMounted(async () => {
scrollChatToBottom(false)
})
// 切换会话时,若该会话来自后端且尚未加载本地消息,则拉取历史消息填充
watch(
() => activeSessionId.value,
async (id) => {
if (!id) return
const session = sessions.value.find((item) => item.id === id)
if (session && session.fromServer && !session.messages?.length) {
await loadSessionMessages(session)
scrollChatToBottom(false)
}
},
)
watch(
() => activeMessages.value.map((item) => item.content).join('\n'),
() => {
File diff suppressed because it is too large Load Diff