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:
@@ -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)
|
||||
)
|
||||
|
||||
+882
-58
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
])
|
||||
|
||||
-26
@@ -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
Reference in New Issue
Block a user