feat(asr): 本地语音转写接入为一级路由 + 并行工作流合并提交

按用户指示做**一包提交**,不按工作流拆分。本提交刻意混合了多条并行线:

  · 本地 ASR 接管:audio 成为与 chat/embed/image/video 同等的路由类别
    (IsLocalRoute 单一判据、audio 健康探测、default_audio_route、
    auto 占位、GET /api/ai/routes/audio、回退云端时界面明示「音频已出网」)
  · LLM 调用层:ctx 贯穿、ToolCall/ToolSchema、EmptyCompletionError /
    TransientUpstreamError(按错误类型而非文案判重试)
  · 编排 Agent:general_assistant orchestrate/persistence/spec_driver
  · 联网搜索:internal/search(playwright)
  · 网盘:backend + 前端
  · 前端 UI:导航/路由/工作台若干页
  · 交付文档:DELIVERY.md / AR04 / 部署文档的「无 Python」表述据实改写,
    新增 eai_agentplatform-asr.service、asr.env、clonezilla-cleanup 清 ~/asr-poc

不分拆的原因:dev 早期,粒度不该打断工作节奏。且实测过——这些改动
**在编译上是同一个单元**(llm.go 的 ctx 签名变更牵动 12 个调用点,
chat_message.go 的 ctx 改动又与编排重写同处一个 hunk),拆出来的中间态编不过。
详见 TOP_CODING_RULES.md G14.5 与 bugs_and_errors.md E09。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-26 22:21:39 +08:00
co-authored by Claude Code
parent e73169df50
commit c1af86c934
192 changed files with 18046 additions and 392 deletions
@@ -0,0 +1,43 @@
import http from './http'
// 语音转写技能的四步,一步一个端点。
//
// 为什么不复用 /skills/office/execute:office 的那条链路是纯前端产物构造器
// (后端不发网络请求),而转写必须真的调 ASR 和 LLM。
//
// 超时必须逐条覆盖 http 实例的 30s 默认值,否则一小时录音的转写会被前端自己掐断:
// axios 的 timeout 是客户端行为,改 nginx 也没用。
//
// ⚠️ structure / minutes 的超时**是当前整套流程真正的长度上限**,不是随手填的。
// 这两步要让模型逐块加工逐字稿(每块 1200 字),endpoint 是同步的,前端只能干等。
// 一次真实的 26:41 录音(11936 字逐字稿 → 10 块)实测:
//
// 第 1+2 步(确认范围 + 云端 ASR) ≤57s
// 第 3+4 步(两次逐块加工 + 一次归并) 532s,其中**至少一步 ≥266s**
// 全程 589.54s
//
// 也就是说原来的 5 分钟上限,在这份录音上已经被吃到 ≥89% —— 余量不足以覆盖
// 上游那点正常波动(真实内容比合成稿慢约 1.4 倍),某次稍慢就会被前端自己掐断,
// 而后端还在跑、产物也已经落库,用户看到的却是失败。故放宽到 10 分钟。
//
// 这只是把悬崖往后挪,不是把悬崖填平:**再长的音频应当走异步**
// (见 TOP_CODING_RULES.md G12),而不是继续往上抬 axios 超时。
// 10 分钟大约够 50 分钟音频;超过就该改成「提交任务 + 轮询进度」。
const AUDIO_STEPS = {
scope: { url: '/skills/audio/scope', timeout: 60 * 1000 },
transcribe: { url: '/skills/audio/transcribe', timeout: 15 * 60 * 1000 },
// 第 3 步只推断身份,输出一小段 JSON,几十秒的量级;不需要给到分钟级。
speakers: { url: '/skills/audio/speakers', timeout: 5 * 60 * 1000 },
// 第 4 步是**用户自己点的那一下**,没有模型调用,只写一条留痕。
// 超时给到 60s 是给慢网络留的,不是给计算留的。
'speakers-confirm': { url: '/skills/audio/speakers/confirm', timeout: 60 * 1000 },
structure: { url: '/skills/audio/structure', timeout: 10 * 60 * 1000 },
minutes: { url: '/skills/audio/minutes', timeout: 10 * 60 * 1000 },
}
/** 执行一步。step 不在表里就直接抛,不悄悄发到别的端点。 */
export function runAudioSkillStep(step, data) {
const target = AUDIO_STEPS[step]
if (!target) throw new Error(`未知的语音转写步骤:${step}`)
return http.post(target.url, data, { timeout: target.timeout })
}
@@ -0,0 +1,83 @@
import http from './http'
// ── 目录 ──
export function listFolders() {
return http.get('/netdisk/folders')
}
export function createFolder(data) {
return http.post('/netdisk/folders', data)
}
export function patchFolder(id, data) {
return http.patch(`/netdisk/folders/${id}`, data)
}
export function deleteFolder(id) {
return http.delete(`/netdisk/folders/${id}`)
}
// ── 文件 ──
export function listFiles(params) {
return http.get('/netdisk/files', { params })
}
export function uploadNetdiskFile(file, folderPath, extra = {}) {
const formData = new FormData()
formData.append('file', file)
formData.append('folder_path', folderPath)
Object.entries(extra).forEach(([k, v]) => {
if (v === undefined || v === null || v === '') return
formData.append(k, v)
})
return http.post('/netdisk/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
}
export function previewFile(id) {
return http.get(`/netdisk/files/${id}/preview`)
}
export function renameFile(id, filename) {
return http.put(`/netdisk/files/${id}/rename`, { filename })
}
export function moveFile(id, folderPath) {
return http.put(`/netdisk/files/${id}/move`, { folder_path: folderPath })
}
export function recycleFile(id) {
return http.delete(`/netdisk/files/${id}`)
}
// ── 回收站 ──
export function listRecycle() {
return http.get('/netdisk/recycle')
}
export function restoreFile(id) {
return http.post(`/netdisk/recycle/${id}/restore`)
}
export function purgeFile(id) {
return http.delete(`/netdisk/recycle/${id}`)
}
// ── 审批(管理员)──
export function auditNetdiskList(params) {
return http.get('/netdisk/audit-list', { params })
}
export function auditNetdiskFile(id, data) {
return http.post(`/netdisk/audit/${id}`, data)
}
// 下载链接(需登录态,走 blob 更稳)
export function downloadUrl(id) {
return `/api/netdisk/files/${id}/download`
}
export function previewUrl(storedName) {
return `/netdisk-file/${storedName}`
}
@@ -36,6 +36,12 @@ export function listAiEmbedRoutes() {
return http.get('/ai/routes/embed')
}
// 语音转写(ASR)路由。与 chat/embed 同形,多一个 is_local 字段标明
// 音频会不会出网(判据是 base_url 是否回环,见后端 config.IsLocalRoute)。
export function listAiAudioRoutes() {
return http.get('/ai/routes/audio')
}
// —— 管理员统计 ——
export function getDashboard() {
return http.get('/system/dashboard')
@@ -0,0 +1,227 @@
<template>
<div class="asc-card">
<div class="asc-head">
<span class="asc-eyebrow">需要你核对</span>
<h4 class="asc-title">这段录音里,谁是谁?</h4>
</div>
<p class="asc-lead">
机器只听得出一共
<b>{{ rows.length }}</b>
个人在说话,但它不知道这些人是谁。下面这些身份是它<strong>从稿内线索推的</strong>
—— 自我介绍、互相称呼、职务称谓。请你对一遍:
<b>没有依据的字段请留空</b>,留空不会出错,编一个名字才会。
</p>
<div class="asc-table">
<div class="asc-row asc-row--head">
<span class="asc-cell asc-cell--tag">稿里</span>
<span class="asc-cell">机构名</span>
<span class="asc-cell">头衔</span>
<span class="asc-cell">姓名</span>
<span class="asc-cell asc-cell--why">模型给的依据</span>
</div>
<div v-for="row in rows" :key="row.key" class="asc-row">
<span class="asc-cell asc-cell--tag">说话人 {{ row.key }}</span>
<el-input v-model="row.org" size="small" placeholder="留空" />
<el-input v-model="row.title" size="small" placeholder="留空" />
<el-input v-model="row.name" size="small" placeholder="留空" />
<span class="asc-cell asc-cell--why">
<template v-if="row.evidence">{{ row.evidence }}</template>
<span v-else class="asc-none">稿里没有依据</span>
</span>
</div>
</div>
<!-- 全都是空的时候要提前说清楚后果:确认之后稿子里还是「说话人 0」,
而不是悄悄安一个名字上去。 -->
<p v-if="unnamedCount" class="asc-warn">
有 {{ unnamedCount }} 位没有任何依据。确认后他们在后续产物里仍显示为
「说话人 N」—— 这比安一个听起来合理的名字要好:后者读起来毫无破绽,
而纪要一旦发出去,追责的时候找的是那个人。
</p>
<div class="asc-actions">
<el-button type="primary" :loading="busy" :disabled="busy" @click="submit">
确认,继续整理与生成纪要
</el-button>
<span class="asc-hint">
确认之前不会继续。推断出来的身份一旦写进纪要,读者看不出它是猜的。
</span>
</div>
</div>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
const props = defineProps({
// 每位说话人一项:{ key, org, title, name, evidence }
speakers: { type: Array, default: () => [] },
busy: { type: Boolean, default: false },
})
const emit = defineEmits(['confirm'])
// 用本地副本编辑,不直接改 props:确认前用户来回改几次是常态,
// 而 props 是「服务端给的那一份推断结果」,被就地改掉之后就再也回不去了
// (想做「还原成模型推断的」都没了基准)。
const rows = reactive([])
watch(
() => props.speakers,
(list) => {
rows.splice(0, rows.length, ...(Array.isArray(list) ? list : []).map((item) => ({
key: String(item?.key ?? ''),
org: String(item?.org ?? ''),
title: String(item?.title ?? ''),
name: String(item?.name ?? ''),
evidence: String(item?.evidence ?? ''),
})))
},
{ immediate: true, deep: true },
)
// 「三位信息全空」的那几位。它们会被原样留在稿子里,所以单独提示一句。
const unnamedCount = computed(() => rows.filter(
(row) => !row.org.trim() && !row.title.trim() && !row.name.trim(),
).length)
function submit() {
if (props.busy) return
emit('confirm', rows.map((row) => ({
key: row.key,
org: row.org.trim(),
title: row.title.trim(),
name: row.name.trim(),
})))
}
</script>
<style scoped>
.asc-card {
border: 1px solid #d9e1ff;
border-left: 3px solid #5263c3;
border-radius: 10px;
background: #f7f9ff;
padding: 16px 18px;
margin: 12px 0;
}
.asc-eyebrow {
display: inline-block;
font-size: 12px;
color: #5263c3;
background: #e8ecff;
border-radius: 999px;
padding: 1px 10px;
margin-bottom: 6px;
}
.asc-title {
margin: 0 0 8px;
font-size: 15px;
font-weight: 600;
color: #1f2a44;
}
.asc-lead {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.7;
color: #47506b;
}
.asc-table {
border: 1px solid #e2e7f5;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.asc-row {
display: grid;
grid-template-columns: 84px 1fr 1fr 1fr 1.4fr;
gap: 8px;
align-items: center;
padding: 6px 10px;
border-top: 1px solid #eef1f9;
}
.asc-row:first-child {
border-top: none;
}
.asc-row--head {
background: #f4f6fc;
font-size: 12px;
color: #7a839c;
}
.asc-cell {
min-width: 0;
font-size: 13px;
color: #3b4258;
}
.asc-cell--tag {
font-size: 12px;
color: #5263c3;
font-weight: 600;
}
.asc-cell--why {
font-size: 12px;
color: #7a839c;
line-height: 1.5;
}
.asc-none {
color: #b6bccd;
}
.asc-warn {
margin: 12px 0 0;
font-size: 12px;
line-height: 1.7;
color: #9a6b1f;
background: #fff8e8;
border: 1px solid #f5e3bd;
border-radius: 8px;
padding: 8px 12px;
}
.asc-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-top: 14px;
}
.asc-hint {
font-size: 12px;
color: #8b93a8;
line-height: 1.6;
}
/* 窄屏:一张五列的表在手机上没法填(每个输入框只剩几十像素)。
改成一行一个人、字段竖排,表头整行隐掉 —— 表头是给横排看的,
竖排之后「机构名 / 头衔 / 姓名」只能靠 placeholder 认,所以 placeholder
必须留在输入框上(不是省事的空串)。 */
@media (max-width: 720px) {
.asc-row--head {
display: none;
}
.asc-row {
grid-template-columns: 1fr;
gap: 6px;
padding: 10px;
}
.asc-cell--why {
padding-top: 2px;
}
}
</style>
@@ -61,9 +61,15 @@ const isGeneralAssistant = computed(() => {
})
const kind = computed(() => {
if (isGeneralAssistant.value) return 'specialist'
const focusedKind = currentFocusedObjectKind.value
// 明确聚焦的技能要排在「通用专员」之前判断。
//
// 通用专员里可以挂技能,挂上之后用户当前操作的其实是那个技能 —— 右栏已经
// 显示成「语音转写 · 技能工作流」了,这里若还因为 isGeneralAssistant 抢先
// 返回 'specialist',同一屏上就会出现「我到底在用什么」的自相矛盾:
// 右栏说语音转写,这个标签说通用专员。
if (focusedKind === 'skill' && skillEntry.value) return 'skill'
if (isGeneralAssistant.value) return 'specialist'
if (focusedKind === 'specialist') {
const key = currentSpecialistKey.value
if (key && key !== GENERAL_ASSISTANT_KEY) return 'specialist'
@@ -0,0 +1,274 @@
<template>
<el-dialog
:model-value="modelValue"
title="从网盘文件选择"
width="760px"
class="netdisk-picker-dialog"
@update:model-value="closeDialog"
@closed="reset"
>
<div class="ndp-body">
<!-- 左侧目录树 -->
<div class="ndp-tree">
<el-tree
:data="folderTree"
node-key="path"
:props="{ label: 'display_name', children: 'children' }"
highlight-current
:default-expanded-keys="['/']"
@node-click="onFolderClick"
>
<template #default="{ data }">
<div class="ndp-tree-node">
<el-icon><Folder /></el-icon>
<span class="ndp-tree-label">{{ data.display_name }}</span>
</div>
</template>
</el-tree>
<el-empty v-if="!loadingFolders && folderTree.length === 0" :image-size="48" description="暂无目录" />
</div>
<!-- 右侧文件列表 -->
<div class="ndp-files">
<div class="ndp-crumb">{{ currentPath === '/' ? '根目录' : currentPath }}</div>
<el-table
ref="tableRef"
:data="files"
border
stripe
size="small"
height="360"
v-loading="loadingFiles"
@selection-change="onSelect"
>
<el-table-column type="selection" width="42" :selectable="canSelect" />
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.filename }}</span>
</template>
</el-table-column>
<el-table-column label="大小" width="90">
<template #default="{ row }">{{ fmtSize(row.file_size) }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingFiles && files.length === 0" :image-size="48" description="该目录暂无已审批文件" />
</div>
</div>
<template #footer>
<div class="ndp-footer">
<span class="ndp-count">已选 {{ selected.length }} 个文件</span>
<el-button @click="closeDialog">取消</el-button>
<el-button type="primary" :disabled="!selected.length" :loading="confirming" @click="confirm">
确认选择
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Folder } from '@element-plus/icons-vue'
import { listFolders, listFiles } from '@/api/netdisk'
import http from '@/api/http'
const props = defineProps({
modelValue: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'confirm-files'])
const rootFolderPath = '/'
const loadingFolders = ref(false)
const loadingFiles = ref(false)
const confirming = ref(false)
const folders = ref([])
const files = ref([])
const selected = ref([])
const currentPath = ref('/')
const tableRef = ref(null)
const folderTree = computed(() => buildTree(currentPath.value))
function buildTree() {
const root = { path: rootFolderPath, display_name: '/', children: [] }
const map = { [rootFolderPath]: root }
const sorted = [...folders.value].sort((a, b) => a.path.localeCompare(b.path))
for (const f of sorted) {
const node = { id: f.id, path: f.path, display_name: f.display_name || f.path.split('/').pop(), children: [] }
map[f.path] = node
const parentPath = f.path.slice(0, f.path.lastIndexOf('/')) || '/'
const parent = map[parentPath]
if (parent) parent.children.push(node)
else root.children.push(node)
}
return [root]
}
async function loadFolders() {
loadingFolders.value = true
try {
const res = await listFolders()
folders.value = Array.isArray(res?.data?.items) ? res.data.items : []
} catch (error) {
console.warn('load netdisk folders failed', error)
folders.value = []
} finally {
loadingFolders.value = false
}
}
async function loadFiles() {
loadingFiles.value = true
selected.value = []
try {
const res = await listFiles({ path: currentPath.value, status: 'approved' })
files.value = Array.isArray(res?.data?.items) ? res.data.items : []
} catch (error) {
console.warn('load netdisk files failed', error)
files.value = []
} finally {
loadingFiles.value = false
}
}
function onFolderClick(node) {
if (node?.path === currentPath.value) return
currentPath.value = node?.path || rootFolderPath
loadFiles()
}
function canSelect(row) {
return row.status === 'approved'
}
function onSelect(rows) {
selected.value = rows
}
function fmtSize(bytes) {
const n = Number(bytes) || 0
if (n < 1024) return `${n} B`
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
}
function closeDialog() {
emit('update:modelValue', false)
}
function reset() {
currentPath.value = rootFolderPath
files.value = []
selected.value = []
}
async function confirmFiles(filesToUse) {
const prepared = []
for (const row of filesToUse) {
try {
// 从网盘拉取已审批文件,转成 File 对象复用上层统一的上传物链路(即“复制”一份参与任务)。
const blob = await http.get(`/netdisk/files/${row.id}/download`, { responseType: 'blob' })
const file = new File([blob], row.filename, { type: row.mime_type || 'application/octet-stream' })
prepared.push(file)
} catch (error) {
console.warn(`pull netdisk file ${row.filename} failed`, error)
}
}
return prepared
}
async function confirm() {
if (!selected.value.length) return
confirming.value = true
try {
const filesToUse = await confirmFiles(selected.value)
const ok = filesToUse.length === selected.value.length
closeDialog()
if (filesToUse.length) {
emit('confirm-files', filesToUse)
}
if (!ok) {
// 部分/全部拉取失败时给出提示,避免静默丢文件。
const { ElMessage } = await import('element-plus')
if (filesToUse.length === 0) ElMessage.error('所选文件拉取失败,请重试')
else ElMessage.warning('部分网盘文件拉取失败,已跳过')
}
} finally {
confirming.value = false
}
}
onMounted(async () => {
await loadFolders()
await loadFiles()
})
watch(() => props.modelValue, (val) => {
if (val) {
currentPath.value = rootFolderPath
loadFolders()
loadFiles()
}
})
</script>
<style scoped>
.ndp-body {
display: flex;
gap: 12px;
min-height: 380px;
}
.ndp-tree {
width: 220px;
flex: none;
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 8px;
overflow: auto;
max-height: 400px;
}
.ndp-tree-node {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.ndp-tree-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ndp-files {
flex: 1;
min-width: 0;
}
.ndp-crumb {
font-size: 13px;
color: #909399;
padding: 0 2px 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ndp-footer {
display: flex;
align-items: center;
gap: 12px;
}
.ndp-count {
flex: 1;
text-align: left;
font-size: 13px;
color: #909399;
}
</style>
@@ -30,10 +30,11 @@
<button
type="button"
class="plus-row plus-row-action"
@click="triggerFilePicker"
@click="openSourceMenu"
>
<el-icon class="plus-row-icon"><UploadFilled /></el-icon>
<span class="plus-row-label">上传文件</span>
<el-icon class="plus-row-arrow"><ArrowRight /></el-icon>
</button>
<div class="plus-l1-divider"></div>
<button
@@ -112,15 +113,50 @@
class="plus-hidden-input"
@change="handleFileChange"
/>
</template>
<!-- 上传来源选择:从网盘选 / 上传本地文件 -->
<el-dialog
:model-value="sourceMenuVisible"
title="上传文件"
width="380px"
align-center
:show-close="true"
@update:model-value="sourceMenuVisible = $event"
>
<div class="plus-source-list">
<button type="button" class="plus-source-item" @click="chooseSource('netdisk')">
<el-icon class="plus-source-icon"><FolderOpened /></el-icon>
<div class="plus-source-main">
<span class="plus-source-title">从网盘文件选择</span>
<span class="plus-source-caption">从团队网盘已有文件中选择/复制</span>
</div>
<el-icon class="plus-source-arrow"><ArrowRight /></el-icon>
</button>
<button type="button" class="plus-source-item" @click="chooseSource('local')">
<el-icon class="plus-source-icon"><UploadFilled /></el-icon>
<div class="plus-source-main">
<span class="plus-source-title">上传本地文件</span>
<span class="plus-source-caption">从本机选择文件上传</span>
</div>
<el-icon class="plus-source-arrow"><ArrowRight /></el-icon>
</button>
</div>
</el-dialog>
<!-- 网盘文件选择 -->
<NetdiskFilePickerDialog
v-model="netdiskPickerVisible"
@confirm-files="onNetdiskConfirm"
/></template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import {
ArrowRight, Avatar, Check, Connection, EditPen, MagicStick, Plus, Search, Setting, UploadFilled,
ArrowRight, Avatar, Check, Connection, EditPen, FolderOpened, MagicStick, Plus, Search, Setting, UploadFilled,
} from '@element-plus/icons-vue'
import NetdiskFilePickerDialog from '@/components/chat/NetdiskFilePickerDialog.vue'
import { listConnectors } from '@/connectors/api/connector'
import { useAuthStore } from '@/store/auth'
import { useSkillCatalogStore } from '@/skills/store/skillCatalog'
@@ -162,6 +198,8 @@ const specialistCatalog = useSpecialistCatalogStore()
const taskRuntime = useTaskRuntimeStore()
const isAdmin = computed(() => auth.isAdmin)
const open = ref(false)
const sourceMenuVisible = ref(false)
const netdiskPickerVisible = ref(false)
const keyword = ref('')
const activeKey = ref('')
const fileInputRef = ref(null)
@@ -296,6 +334,27 @@ function connectorCaption(connector) {
return `当前可见 ${objects} 个对象定义`
}
// 点「上传文件」:先弹出一层来源选择(从网盘选 / 上传本地文件),再按选择走对应入口。
function openSourceMenu() {
close()
sourceMenuVisible.value = true
}
function chooseSource(source) {
sourceMenuVisible.value = false
if (source === 'netdisk') {
netdiskPickerVisible.value = true
return
}
triggerFilePicker()
}
// 从网盘选中的文件已被转成 File 对象,直接走与「上传本地文件」相同的统一链路。
function onNetdiskConfirm(files) {
if (!files?.length) return
emit('pick-files', files)
}
async function triggerFilePicker() {
close()
if (typeof window !== 'undefined' && typeof window.showOpenFilePicker === 'function') {
@@ -687,6 +746,63 @@ async function pickItem(item) {
.plus-hidden-input {
display: none;
}
/* 上传来源选择面板 */
.plus-source-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.plus-source-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 12px;
border: 1px solid #ebeef5;
border-radius: 10px;
background: transparent;
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.plus-source-item:hover {
background: #f5f7fa;
border-color: #c6e2ff;
}
.plus-source-icon {
font-size: 20px;
color: #409eff;
flex: none;
}
.plus-source-main {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.plus-source-title {
font-size: 14px;
color: #303133;
}
.plus-source-caption {
font-size: 12px;
color: #909399;
}
.plus-source-arrow {
font-size: 14px;
color: #c0c4cc;
flex: none;
}
</style>
<style>
@@ -82,8 +82,10 @@
<span class="sp-art-m">{{ art.artifact_type || art.artifactType || 'text' }}<template v-if="art.created_at"> · {{ hhmm(art.created_at) }}</template></span>
</div>
<!-- 推断出来的进行中:没有真实百分比,所以用不确定进度条 -->
<div v-if="step.flowState === 'running'" class="sp-indet"><i></i></div>
<!-- 推断出来的进行中:没有真实百分比,所以用不确定进度条。
等用户的那一步不画:它不是在跑,是在等一个人,画根动来动去的
进度条会让人以为机器还在干活。 -->
<div v-if="step.flowState === 'running' && !step.awaitUser" class="sp-indet"><i></i></div>
</div>
</div>
@@ -207,10 +209,17 @@ const hasTask = computed(() => Boolean(task.value?.id))
const runs = computed(() => detail.value?.runs || [])
const artifacts = computed(() => detail.value?.artifacts || [])
const taskContext = computed(() => parseTaskContext(task.value))
// 带步骤后缀的 run 也算这个技能的:转写那类多步技能每步写一条
// `技能key:步骤`,只按 key 全等过滤的话,四条 run 一条都匹配不上,
// 右栏产物区会空着,而工作流那边又拿不到 run 去判断每一步做没做。
const skillRuns = computed(() => {
const key = String(currentFocusedObjectKey.value || currentSkillKey.value || '').trim()
if (!key) return []
return runs.value.filter((item) => String(item.action_key || item.actionKey || '').trim() === key)
const prefix = `${key}:`
return runs.value.filter((item) => {
const actionKey = String(item.action_key || item.actionKey || '').trim()
return actionKey === key || actionKey.startsWith(prefix)
})
})
const skillLiveArtifacts = computed(() => {
if (panelKind.value !== 'skill') return []
@@ -218,6 +227,40 @@ const skillLiveArtifacts = computed(() => {
if (!runIds.size) return []
return artifacts.value.filter((item) => runIds.has(Number(item.created_by_run_id || item.createdByRunId || 0)))
})
/**
* 取某一步**最后一次**跑出来的那条 run。
*
* 同一步重复跑过时(重跑「识别说话人身份」就会),只有最后一次的产物是当前
* 生效的那份。原来用 find() 取第一条:重跑之后「产出」一直指向上一版名单,
* 用户对着一份已经作废的名单点确认,而界面上完全看不出这是旧的。
* 公众号那个面板踩过同一个坑,那边的注释写着「重复执行时第一条 run 早已过期」。
*
* actionKey 传空串表示「这个技能没有逐步声明动作」(那 10 个办公技能),
* 此时取它最近的一条 run 即可。
*/
function latestRunOfActionKey(actionKey = '') {
let latest = null
for (const entry of skillRuns.value) {
const key = String(entry.action_key || entry.actionKey || '').trim()
if (actionKey && key !== actionKey) continue
if (!latest || Number(entry.id || 0) > Number(latest.id || 0)) latest = entry
}
return latest
}
// 这条音频路由输不输出说话人标签。
//
// 读第 1 步「转写要求」产物里的 speakers 布尔值(后端写的是
// route.SupportsSpeakers)。读不到就当**不知道**,照常显示那一步 ——
// 猜错方向会让一步真的该做的事从界面上消失,比多显示一格糟得多。
const speakersUnsupported = computed(() => {
for (const item of artifacts.value) {
const payload = parseArtifactPayload(item)
if (typeof payload?.speakers === 'boolean') return payload.speakers === false
}
return false
})
const skillBlueprint = computed(() => {
const workflow = Array.isArray(skill.value?.workflowSchema) ? skill.value.workflowSchema : []
const outputs = Array.isArray(skill.value?.artifactSchema) ? skill.value.artifactSchema : []
@@ -232,19 +275,51 @@ const steps = computed(() =>
buildActionRecords({ specialist: specialist.value, runs: runs.value }),
task.value,
)
: skillBlueprint.value.workflow.map((item, index) => ({
key: `skill-step-${index}-${item.title || 'step'}`,
actionKey: '',
title: item.title || `步骤 ${index + 1}`,
detail: item.detail || '',
outputs: item.expectedOutput || '',
flowState: skillRuns.value.length ? 'done' : 'todo',
flowInferred: false,
runId: skillRuns.value[0]?.id || null,
startedAt: skillRuns.value[0]?.started_at || '',
finishedAt: skillRuns.value[0]?.finished_at || '',
templateArtifacts: skillLiveArtifacts.value,
}))
: skillBlueprint.value.workflow
.map((item, index) => {
const actionKey = String(item.actionKey || '').trim()
// 声明了 actionKey 的步骤逐步判断;没声明的(那 10 个办公技能)
// 保持原来的「有 run 就算做完」,那 10 个技能的显示与改动前一致。
//
// 配对用显式 actionKey,**不用中文步骤名** —— 本仓库吃过这个亏:
// 改个名就把配对悄悄改断了,界面上不会报错,只会显示成没做。
const matchedRun = latestRunOfActionKey(actionKey)
// 等人拍板的步骤没有 run,但它不是「没开始」,是「轮到你了」。
// 显示成「未开始」的话,用户会一直等一台机器,而机器在等他。
//
// 判据是**任务状态**,不看有没有历史 run:重新识别一次说话人之后,
// 任务被置回「待确认」,而上一轮那次确认的 run 还留在库里 ——
// 只看 run 的话这一步会显示成「已完成」,用户以为确认过了,于是
// 既不去确认、也没人告诉他要去确认,任务就停在那儿。
const awaitingUser = Boolean(item.awaitUser)
&& String(task.value?.status || '').trim() === '待确认'
return {
key: `skill-step-${index}-${item.title || 'step'}`,
actionKey,
title: item.title || `步骤 ${index + 1}`,
detail: item.detail || '',
outputs: item.expectedOutput || '',
// awaitingUser 压在 matchedRun 前面:任务停在「待确认」时,这一步
// 当前的状态就是「等你」,哪怕它历史上跑完过。
flowState: actionKey
? (awaitingUser ? 'running' : (matchedRun ? 'done' : 'todo'))
: (skillRuns.value.length ? 'done' : 'todo'),
awaitUser: awaitingUser,
// 这一步在这条音频路由下**根本没有对象可做**(不输出说话人分离),
// 留着会让「x/y 完成」永远差一格,看起来像漏做了一步。
inapplicable: Boolean(item.conditional) && !matchedRun && speakersUnsupported.value,
flowInferred: false,
runId: matchedRun?.id || null,
startedAt: matchedRun?.started_at || '',
finishedAt: matchedRun?.finished_at || '',
templateArtifacts: skillLiveArtifacts.value,
}
})
// 不适用的条件步骤直接从列表里去掉,而不是画一条灰的:灰的也要占一格
// 分母,用户还是要自己去想「这格为什么一直空着」。
.filter((step) => !step.inapplicable)
)
// liveOnly:产物 tab 里每一条都必须是这条任务真的产出的 task_artifact。
@@ -374,12 +449,30 @@ const emptyUploadText = computed(() => (
))
function artifactsOf(step) {
if (panelKind.value === 'skill') return step.templateArtifacts || []
// 技能面板也要按**这一步自己的 run** 挑产物。
//
// 原来是把这个技能的全部产物原样挂在每一步下面,单步技能(那 10 个办公技能)
// 看不出来,转写这种六步技能就会变成每一步都宣称产出过「结构化纪要」和
// 「逐字转写稿」—— 用户照着这份列表去核对「第 1 步到底产出了什么」,
// 找到的全是别的步骤的东西。产物本来就靠 created_by_run_id 挂在 run 上,
// 这里按 run 挑是把它读对了,不是新加规则。
if (panelKind.value === 'skill') return artifactsOfStep(step.runId, skillLiveArtifacts.value)
return artifactsOfStep(step.runId, artifacts.value)
}
function stageLabel(step) {
if (panelKind.value === 'skill') return skillRuns.value.length ? '已完成' : '建议步骤'
if (panelKind.value === 'skill') {
// 一条 run 都没跑过时,这列显示的是**模板**,不是进度。
if (!skillRuns.value.length) return '建议步骤'
// 跑过之后必须逐步看,不能拿 skillRuns.length 一概而论:那样跑完第 1 步,
// 后面每一步都会被标成「已完成」—— 数字角标也全都变成 ✓。
// 这正是「右侧工作流从来不变」的另一半原因(另一半是没逐步刷新)。
if (step.awaitUser && step.flowState !== 'done') return '待你确认'
if (step.flowState === 'done') return '已完成'
if (step.flowState === 'running') return '进行中'
if (step.flowState === 'err') return '出错'
return '未开始'
}
if (step.flowState === 'done') return '已完成'
if (step.flowState === 'running') return '进行中'
if (step.flowState === 'err') return '出错'
+14 -6
View File
@@ -4,7 +4,7 @@
// 自己的 items。之前「专员·技能·APP·连接器」做成了 groups 套 items 的两层结构,
// 现在把它收平:5 个 tab(专员 / 技能 / xApp / 连接器 / 工坊)都只是“浏览配置”的目录页入口,
// 不再在这一层承担“进入可执行页面”的职责。
import { Avatar, CirclePlus, Collection, Grid, More, Share } from '@element-plus/icons-vue'
import { Avatar, CirclePlus, Collection, FolderOpened, Grid, More, Share } from '@element-plus/icons-vue'
import {
INTERNAL_EXAM_XAPP_ROOT,
INTERNAL_TRAINING_XAPP_ROOT,
@@ -65,15 +65,23 @@ export const navSections = [
{ label: '成绩查询', pageRoute: '/knowledge/exam-records' },
],
},
{
// 团队共享网盘:全公司目录树 + 文件上传/下载/在线预览/回收站/审批。
// 排在知识库之后。
key: 'netdisk',
label: '6. 网盘',
icon: FolderOpened,
items: [{ label: '团队网盘', pageRoute: '/netdisk' }],
},
{
key: 'admin',
label: '6. 后台管理',
label: '7. 后台管理',
icon: More,
items: [
{ label: '6.1 用户管理', pageRoute: '/system/users', adminOnly: true },
{ label: '6.2 组织与公司配置', pageRoute: '/system/organization', adminOnly: true },
{ label: '6.3 系统配置', pageRoute: '/system/config', adminOnly: true },
{ label: '6.4 AI后台管理', pageRoute: '/system/ai-admin', adminOnly: true },
{ label: '7.1 用户管理', pageRoute: '/system/users', adminOnly: true },
{ label: '7.2 组织与公司配置', pageRoute: '/system/organization', adminOnly: true },
{ label: '7.3 系统配置', pageRoute: '/system/config', adminOnly: true },
{ label: '7.4 AI后台管理', pageRoute: '/system/ai-admin', adminOnly: true },
],
},
]
@@ -22,6 +22,7 @@ const routes = [
// 项目:装着任务的容器。列表是落地页,详情页带「任务」tab。
{ path: 'projects', name: 'ProjectsPage', component: () => import('@/views/workbench/ProjectsPage.vue') },
{ path: 'projects/:id', name: 'ProjectDetailPage', component: () => import('@/views/workbench/ProjectDetailPage.vue') },
{ path: 'netdisk', name: 'NetdiskPage', component: () => import('@/views/netdisk/NetdiskPage.vue') },
{ path: 'xapps', name: 'XAppDirectoryPage', component: () => import('@/xapps/views/XAppDirectoryPage.vue') },
{ path: 'overview', redirect: '/projects' },
{ path: 'my/settings', name: 'MySettingsPage', component: () => import('@/views/me/MySettingsPage.vue') },
@@ -14,6 +14,7 @@
// 这里就是唯一的一份)。
// 「真发给后端的」和「只是摆着的」在每条上标了 note,别让后来人以为都能用。
import { assistantModes } from '@/skills/core/builtin'
import { AUDIO_FILE_FORMATS } from '@/skills/shared/types'
// 7 种语言,文档翻译的源/目标共用一份(顺序照它的 markup:源列表 zh 打头,目标列表 en 打头)
const LANGS = [
@@ -93,7 +94,9 @@ export const skillSettings = {
],
},
// language 真发出去:语音转写工具当前执行链会把它传给 transcribe API。
// language 真发出去:SmartAssistantPage 的 send() 把它随执行上下文交给
// executeAudioSkill,再作为 scope / transcribe 两步的 language 入参。
// 选「自动识别」时发的是 'auto',后端会当成不指定、不写进请求。
'audio-transcribe': {
ico: '音',
settings: [
@@ -103,8 +106,11 @@ export const skillSettings = {
{ value: 'en', label: 'English' },
{ value: 'auto', label: '自动识别' },
] },
// 这一条在页面上本来就是个纯文字提示,不是控件,照原样搬过来
{ key: 'format_hint', label: '音频格式', type: 'static', text: 'MP3 / WAV / M4A / OGG / FLAC' },
// 这一条在页面上本来就是个纯文字提示,不是控件。
// 文案从 AUDIO_FILE_FORMATS 生成而不是手写死:手写的那份漏了 aac/wma,
// 而发送门控与后端都认它们 —— 提示说不行、实际能用,比不说还糟。
{ key: 'format_hint', label: '音频格式', type: 'static',
text: AUDIO_FILE_FORMATS.map((item) => item.toUpperCase()).join(' / ') },
],
},
@@ -18,15 +18,31 @@ export function createInteractionCard({
}
}
/**
* @param {string} actionKey 该步骤对应的 task_run.action_key。声明了它,右栏就会
* 按「这一步的 run 在不在」逐步点亮;不声明(留空)则退化为「有任意一条 run 就整列点亮」。
* 用显式 key 而不是拿中文步骤名去配对:改名会静默断链,这个仓库已经吃过一次亏。
* @param {boolean} conditional 这一步**可能合理地不跑**。比如「识别说话人身份」,
* 音频路由不带说话人分离时(Qwen3-ASR)就没有身份可识别,此时不该把它显示成
* 「漏做了一步」。不声明的步骤一律被当作必做,没跑就是没跑。
* @param {boolean} awaitUser 这一步等的是**人**,不是机器。它没有 run 不代表没开始,
* 而是「轮到你了」—— 右栏据此显示「待你确认」,而不是「未开始」。
*/
export function createSkillWorkflowStep({
title,
detail,
expectedOutput = '',
actionKey = '',
conditional = false,
awaitUser = false,
} = {}) {
return {
title,
detail,
expectedOutput,
actionKey,
conditional,
awaitUser,
}
}
@@ -1,9 +1,62 @@
import {
createOfficeMeta,
createSkillArtifact,
createSkillManifest,
createSkillWorkflowStep,
} from '@/skills/contracts/manifest'
// 六步各自对应后端一条 task_run,action_key 逐字对齐
// internal/skills/api/audio_handlers.go 的 audioStepSpecs —— 两边改一处就得改另一处。
//
// 第 3、4 步是**一个人机对话的停顿**,不是一个技术步骤:转写稿出来之后,稿子里
// 只有「说话人 0」「说话人 1」,要让纪要里写出「张三说」,就必须先弄清谁是谁。
// 而平台里没有人员花名册(全库没有人员实体),这个名字只能从稿内线索推断、
// 再请人过目。所以拆成「推断」与「确认」两步:AI 只负责把候选摆上来,拍板的是人。
//
// 少了这一步,纪要里就是「说话人 0 认为……」—— 一份没人名、事后无从追责的记录。
const AUDIO_STEPS = [
{
title: '确认音频范围与语言',
detail: '先判断音频语言、时长、说话场景,以及是否需要区分说话人。',
expectedOutput: '转写要求',
actionKey: 'audio-transcribe:scope',
},
{
title: '生成逐字转写文本',
detail: '尽量保留原始信息,避免在转写阶段过早删减内容。',
expectedOutput: '原始转写稿',
actionKey: 'audio-transcribe:transcribe',
},
{
title: '识别说话人身份',
detail: '从自我介绍、互相称呼、职务称谓里推断每位说话人的机构、头衔与姓名,只推断、不拍板。',
expectedOutput: '说话人名单',
actionKey: 'audio-transcribe:speakers',
// 音频路由不带说话人分离时(如 Qwen3-ASR)就没有身份可识别,
// 这一步合理缺席,不该被显示成「漏做了一步」。
conditional: true,
},
{
title: '确认说话人身份',
detail: '由你核对机构名、头衔与姓名并确认。确认之前不会继续整理与生成纪要。',
expectedOutput: '已确认的说话人名单',
actionKey: 'audio-transcribe:speakers-confirm',
awaitUser: true,
},
{
title: '整理段落与重点',
detail: '按主题、说话人或时间顺序把文本收成可阅读结构。',
expectedOutput: '结构化转写稿',
actionKey: 'audio-transcribe:structure',
},
{
title: '提炼可交付纪要',
detail: '根据需要输出会议纪要、行动项或重点摘要。',
expectedOutput: '纪要或行动项',
actionKey: 'audio-transcribe:minutes',
},
]
export default createSkillManifest({
key: 'audio-transcribe',
label: '会议音频分析(转写)',
@@ -11,28 +64,7 @@ export default createSkillManifest({
description: '一小时录音,自动出纪要。 操作:打开「会议音频分析」,上传音频(MP3/M4A/WAV)→ AI 自动转写(带说话人标签)→ 同时输出纪要 + 待办清单 + 思维导图',
color: '#5263c3',
skillTags: ['逐字转写', '说话人分段', '重点提炼', '纪要整理'],
workflowSchema: [
createSkillWorkflowStep({
title: '确认音频范围与语言',
detail: '先判断音频语言、时长、说话场景,以及是否需要区分说话人。',
expectedOutput: '转写要求',
}),
createSkillWorkflowStep({
title: '生成逐字转写文本',
detail: '尽量保留原始信息,避免在转写阶段过早删减内容。',
expectedOutput: '原始转写稿',
}),
createSkillWorkflowStep({
title: '整理段落与重点',
detail: '按主题、说话人或时间顺序把文本收成可阅读结构。',
expectedOutput: '结构化转写稿',
}),
createSkillWorkflowStep({
title: '提炼可交付纪要',
detail: '根据需要输出会议纪要、行动项或重点摘要。',
expectedOutput: '纪要或行动项',
}),
],
workflowSchema: AUDIO_STEPS.map((step) => createSkillWorkflowStep(step)),
artifactSchema: [
createSkillArtifact({
title: '逐字转写稿',
@@ -40,6 +72,13 @@ export default createSkillManifest({
artifactType: 'transcript',
fileFormat: 'txt',
}),
createSkillArtifact({
title: '说话人名单',
detail: '每位说话人的机构名、头衔与姓名,需经你确认后才写进后续产物。',
artifactType: 'speakers',
fileFormat: 'md',
status: '待确认',
}),
createSkillArtifact({
title: '结构化纪要',
detail: '按主题或说话人整理后的可读版本。',
@@ -64,4 +103,20 @@ export default createSkillManifest({
'把这段语音整理成适合发群里的会议纪要',
],
},
// officeMeta 不是「这个技能属于办公类」的装饰,它是 registry/eai.js 的准入条件:
// 没有它就进不了 eaiSkillRegistry,选中后 mountedRegistrySkill 为 null,
// 于是整条链路退化成普通聊天 —— 用户看到的就是「上传了音频,AI 反问我发什么」。
officeMeta: createOfficeMeta({
category: 'office',
artifactType: 'transcript',
fileFormat: 'txt',
// 刻意留空:转写的输入是音频附件本身,编一句「请把音频发我」塞进 input_text
// 只会让后端收到一句没人看的话。
defaultPrompt: '',
resultTitle: '逐字转写稿',
summary: '把音频从“听过”变成可查、可引用、可交付的文本资产。',
highlights: ['逐字转写', '说话人分段', '重点提炼', '纪要整理'],
taskPlanTitle: '会议音频分析(转写)流程',
planSteps: AUDIO_STEPS.map((step) => step.title),
}),
})
@@ -8,6 +8,16 @@
<div class="result-status">{{ result?.status || 'ready' }}</div>
</div>
<!-- 警示区:只有技能自己填了 warnings 才出现。
不塞进下面的 highlights 里 —— 那里是一排灰底小圆角标签,
「音频已离开本机」混在里面和「转写模型:X」长得一样,会被直接略过。 -->
<div v-if="warnings.length" class="result-section result-warnings">
<div v-for="(item, index) in warnings" :key="`warn-${index}`" class="warning-row">
<span class="warning-icon" aria-hidden="true">⚠</span>
<span>{{ item }}</span>
</div>
</div>
<div v-if="highlights.length" class="result-section">
<div class="section-label">这次会这样推进</div>
<div class="highlight-list">
@@ -131,6 +141,9 @@ const highlights = computed(() =>
const artifacts = computed(() =>
Array.isArray(props.result?.artifacts) ? props.result.artifacts.filter(Boolean) : []
)
const warnings = computed(() =>
Array.isArray(props.result?.warnings) ? props.result.warnings.filter(Boolean) : []
)
const isOcrMode = computed(() => props.result?.interactive?.type === 'ocr-upload')
async function download(item) {
@@ -247,6 +260,29 @@ async function downloadOcrResult() {
color: #909399;
}
.result-warnings {
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px 12px;
border: 1px solid #f3d19e;
border-radius: 8px;
background: #fdf6ec;
}
.warning-row {
display: flex;
align-items: flex-start;
gap: 6px;
color: #b88230;
font-size: 12px;
line-height: 1.5;
}
.warning-icon {
flex: none;
}
.highlight-list {
display: flex;
flex-wrap: wrap;
@@ -0,0 +1,361 @@
import { runAudioSkillStep } from '@/api/audioSkill'
import { slugify } from './officeArtifacts'
import { AUDIO_FILE_FORMATS, isAudioResource } from './types'
/**
* 语音转写技能的执行器。
*
* 与另外 10 个办公技能的**根本区别**:那些技能的产物是前端算出来的
* (officeExecutionRegistry 里一个个纯函数),转写必须真的把音频送去 ASR、
* 把逐字稿送去 LLM。所以它不走 /skills/office/execute,而是走 /skills/audio/*。
*
* 四步顺序执行、一步一次请求:每步在后端各写一条 task_run,
* 右栏那行「工作流 x/4」数的就是这些 run —— 一次长请求会让它永远停在 1/4。
*/
/**
* 这个技能在注册表里的 key。
*
* 单独拎出来是因为有三处要用同一个字面量:officeSkill 的执行器分派、
* 发送门控里「只挂音频行不行」的判断、以及 manifest 自己的 key。
* 三处各写一遍的话,改技能名时只有一处会漏,而漏掉的那处症状是
* 「挂了音频,发送键亮着,点下去什么也不发生」。这里的 `key` 是**隐式外键**
* —— 它同时是 skill key 和 task_run.action_key 的前缀,改名要连着后端一起改。
*/
export const AUDIO_SKILL_KEY = 'audio-transcribe'
/**
* 重新导出:确认说话人那一步由页面直接调用 —— 它不是技能自动流程的一环,
* 是用户点的那一下。端点表只有 @/api/audioSkill 一份,从技能这一层转出去,
* 页面就不用同时知道「技能在哪」和「端点在哪」两个地方。
*/
export { runAudioSkillStep }
/** 这个技能的输入是音频附件本身,不是打字内容。 */
export function skillConsumesAudio(skillKey) {
return String(skillKey || '').trim() === AUDIO_SKILL_KEY
}
/**
* 自动跑的步骤,跑到「识别说话人身份」为止。
*
* 到这里会**停下等人**:识别出来的只是模型从稿内线索猜的身份,猜错一个,
* 纪要里「张三说」就换了人,而一份通顺的纪要里没人看得出这件事。
* 所以要摆到人面前确认过,才继续往下走(见 resumeAudioSkill)。
*/
const STEP_ORDER = ['scope', 'transcribe', 'speakers']
/** 确认之后才跑的两步。中间隔着一次人工确认,所以不能和上面合成一个循环。 */
const RESUME_STEP_ORDER = ['structure', 'minutes']
const STEP_LABELS = {
scope: '确认音频范围与语言',
transcribe: '生成逐字转写文本',
speakers: '识别说话人身份',
'speakers-confirm': '确认说话人身份',
structure: '整理段落与重点',
minutes: '提炼可交付纪要',
}
/** 从已挂载的附件里挑出第一个音频(挂多个时只处理第一个,不做隐式合并)。 */
export function pickAudioResource(resources) {
const list = Array.isArray(resources) ? resources : []
return list.find((item) => isAudioResource(item) && Number(item?.mediaId) > 0) || null
}
/**
* 通知调用方「又跑完一步」。
*
* 回调抛错不能把整条技能链路带崩:这一步的产物已经落库了,右栏晚一点刷新
* 只是显示滞后,而把异常冒上去会把一次**已经成功**的执行报成失败。
*/
async function onStepDone(context, step, results) {
const notify = context?.onStepDone
if (typeof notify !== 'function') return
try {
await notify({ step, result: results[results.length - 1], results })
} catch (error) {
console.warn('audio skill step callback failed', error)
}
}
/**
* 跑自动的那几步,遇到「需要人拍板」的地方就停下。
*
* 停下时返回的结果带 `pendingReview: true`,调用方据此把确认卡片摆出来;
* 用户确认后由 resumeAudioSkill 接着跑后面两步。
*
* 失败时抛错,且错误信息必须点明是第几步 —— 第 1、2 步的产物此时已经落库了,
* 只说一句「执行失败」,用户会以为整件事都没做,实际上逐字稿可能已经拿到了。
*/
export async function executeAudioSkill(context = {}) {
const definition = context?.definition || {}
const taskId = Number(context?.taskId || 0)
const mediaId = Number(context?.mediaId || 0)
const language = String(context?.language || '').trim()
const aiRouteId = String(context?.aiRouteId || '').trim()
if (!taskId) {
throw new Error('语音转写要把产物存进任务里,请先新建一个任务再发送。')
}
if (!mediaId) {
const formats = AUDIO_FILE_FORMATS.map((item) => item.toUpperCase()).join(' / ')
throw new Error(`请先上传要转写的音频文件(${formats}),再点发送。`)
}
const results = []
for (let index = 0; index < STEP_ORDER.length; index += 1) {
const step = STEP_ORDER[index]
// 第 1 步的产物里带着「这条音频路由输不输出说话人标签」。不带说话人分离时
// (如 Qwen3-ASR),第 3 步没有任何人可识别,后端也会硬失败 —— 那种情况下
// 这个技能就是「转写 + 整理」四步,跳过它即可,而不是报一个看不懂的错。
//
// 只在**明确是 false** 时跳过:读不到这个字段(老后端、字段改名)时照跑,
// 让后端的硬失败把话说清楚,好过默默少做一步。
//
// 走到这一步时 results 里已经有 scope 了(它是第 0 个),所以读得到;
// 不去改循环要遍历的那个数组,跳过的判断就只影响这一次迭代。
if (step === 'speakers' && scopeSupportsSpeakers(results) === false) {
continue
}
// ai_route_id 每一步都带:第 1 步拿它预检这条路由的 max_tokens 够不够跑
// 后面的长文加工,不够就在第 1 步吵,而不是等完一次完整转写才发现。
const payload = { task_id: taskId, ai_route_id: aiRouteId }
if (step === 'scope' || step === 'transcribe') {
payload.media_id = mediaId
payload.language = language
}
try {
const res = await runAudioSkillStep(step, payload)
if (!res?.data) throw new Error('服务没有返回结果')
results.push({ step, ...res.data })
// 每跑完一步就通知外面刷一次右栏。
//
// 不通知的话「工作流 x/6」要等全部跑完才动,而这个技能一次要跑好几分钟
// (实测 26 分钟录音全程 589 秒),中间那几分钟右栏一直停在 0/6 ——
// 看起来和「卡死了」没有区别,用户没法判断到底在跑还是失败了。
await onStepDone(context, step, results)
} catch (error) {
const done = results.length
const tail = done > 0 ? `(前 ${done} 步的产物已保存,可在右侧产物区查看)` : ''
throw new Error(`第 ${index + 1} 步「${STEP_LABELS[step]}」失败:${error?.message || '请稍后重试'}${tail}`)
}
}
const speakersStep = results.find((item) => item.step === 'speakers')
if (speakersStep) {
return buildAudioSkillResult(definition, results, {
pendingReview: true,
speakers: speakerRosterOf(speakersStep),
summary: speakersStep.summary ||
`已推断出 ${speakerRosterOf(speakersStep).length} 位说话人的身份,请核对后确认。`,
})
}
return buildAudioSkillResult(definition, results)
}
/**
* 用户确认完身份之后接着跑「整理段落与重点」与「提炼可交付纪要」。
*
* 为什么确认这件事要在这里再做一次调用、而不是让后端一路跑到底:确认是**人的动作**,
* 中间可能隔几分钟、隔一次刷新、甚至隔一天。后端不为此挂一个等待中的请求。
*/
export async function resumeAudioSkill(context = {}) {
const definition = context?.definition || {}
const taskId = Number(context?.taskId || 0)
const aiRouteId = String(context?.aiRouteId || '').trim()
if (!taskId) {
throw new Error('找不到当前任务,无法继续。请刷新后重试。')
}
const results = []
for (let index = 0; index < RESUME_STEP_ORDER.length; index += 1) {
const step = RESUME_STEP_ORDER[index]
try {
const res = await runAudioSkillStep(step, { task_id: taskId, ai_route_id: aiRouteId })
if (!res?.data) throw new Error('服务没有返回结果')
results.push({ step, ...res.data })
await onStepDone(context, step, results)
} catch (error) {
const done = results.length
const tail = done > 0 ? '(「整理段落与重点」的产物已保存,可在右侧产物区查看)' : ''
throw new Error(`第 ${index + 1} 步「${STEP_LABELS[step]}」失败:${error?.message || '请稍后重试'}${tail}`)
}
}
return buildAudioSkillResult(definition, results, { fileName: context?.fileName || '' })
}
/**
* 第 1 步的产物有没有说「这条音频路由不输出说话人标签」。
*
* 返回值是三态,调用方按三态处理:true 支持、false 明确不支持、null 不知道
* (字段缺失 —— 老后端或字段改名)。把 null 当成 false 会让一步该做的事
* 从流程里静默消失,所以这里不兜底成布尔值。
*/
function scopeSupportsSpeakers(results) {
const scope = results.find((item) => item.step === 'scope')
const value = scope?.result?.speakers
return typeof value === 'boolean' ? value : null
}
/**
* 从「识别说话人身份」那一步的返回值里取出名单。
*
* 兼容两种形状:后端在 result 里回传名单(正常路径),以及只有顶层 speakers
* (接口以后调整时)。取不到就返回空数组 —— 空名单会让调用方显示成
* 「没有需要确认的」,而不是拿 undefined 去渲染表格炸掉。
*/
export function speakerRosterOf(stepResult) {
const candidates = [
stepResult?.result?.speakers,
stepResult?.speakers,
]
for (const item of candidates) {
if (Array.isArray(item)) return item
}
return []
}
/**
* 把已跑完那几步的返回值收成 OfficeSkillResultCard 认识的结果对象。
*
* 会被三种场景调用,所以每一步的内容都要能缺席:
* 跑到一半停下等人确认(只有 scope + transcribe + speakers)、
* 确认后接着跑完(只有 structure + minutes)、
* 以及音频路由不带说话人分离时的直通(scope + transcribe + structure + minutes)。
* 缺席的产物直接从列表里滤掉,不占位、不显示空条目。
*/
function buildAudioSkillResult(definition, results, { pendingReview = false, speakers = null, summary = '', fileName = '' } = {}) {
const byStep = Object.fromEntries(results.map((item) => [item.step, item]))
const scopeInfo = byStep.scope?.result || {}
// fileName 的来路有三个,按可靠性排:这一步自己的 scope 产物 > 调用方带进来的
// (确认后接着跑时,scope 已经不在 results 里了)> 兜底名。
const resolvedName = String(scopeInfo.file_name || fileName || '').trim() || '转写稿'
const slug = slugify(resolvedName.replace(/\.[^.]+$/, ''), 'transcript')
const modelName = byStep.transcribe?.model || scopeInfo.model || ''
// 「这次录音有没有离开本机」必须在结果里明说。
//
// 默认走本机,但本地服务不在时会自动回退云端 —— 那条路径是静默的:
// 用户拿到的是同一份逐字稿,看不出差别。而选本地方案的用户,理由往往就是
// 「录音不出门」,静默出网是这里最不能接受的一种失败。
//
// 措辞整句由后端给(is_local_zh / fell_back_zh),前端不另写一套:
// 两边各写一句,迟早会有一边忘了改。⚠ 由卡片自己画,所以这里剥掉后端的那个。
const transcribeStep = byStep.transcribe || {}
const stripIcon = (text) => String(text || '').replace(/^[⚠\s]+/, '').trim()
const warnings = [
transcribeStep.is_local === false ? stripIcon(transcribeStep.is_local_zh) : '',
transcribeStep.fell_back ? stripIcon(transcribeStep.fell_back_zh) : '',
].filter(Boolean)
const artifacts = [
{
key: 'scope',
title: '转写要求',
description: '这次转写的口径:语言、时长、使用的路由与是否区分说话人。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-scope.md`,
content: byStep.scope?.content || '',
},
{
key: 'transcript',
title: '逐字转写稿',
description: '保留原始信息的基础文本,带时间戳与说话人标签。',
kind: 'text',
fileFormat: 'txt',
mimeType: 'text/plain;charset=utf-8',
downloadName: `${slug}.txt`,
content: byStep.transcribe?.content || '',
},
{
key: 'speakers',
title: pendingReview ? '说话人名单(待确认)' : '说话人名单',
description: '每位说话人的机构名、头衔与姓名,来自稿内线索推断。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-speakers.md`,
content: byStep.speakers?.content || '',
},
{
key: 'structure',
title: '结构化纪要',
description: '按议题或说话人整理后的可读版本。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-structured.md`,
content: byStep.structure?.content || '',
},
{
key: 'minutes',
title: '行动项清单',
description: '适合从会议或访谈中提炼后续动作。',
kind: 'text',
fileFormat: 'md',
mimeType: 'text/markdown;charset=utf-8',
downloadName: `${slug}-minutes.md`,
content: byStep.minutes?.content || '',
},
].filter((item) => String(item.content || '').trim())
const stepTitles = Array.isArray(definition?.officeMeta?.planSteps) && definition.officeMeta.planSteps.length
? definition.officeMeta.planSteps
: Object.values(STEP_LABELS)
const summaryByTitle = Object.fromEntries(
Object.entries(STEP_LABELS).map(([step, label]) => [label, byStep[step]?.summary || '']),
)
const fallbackSummary = pendingReview
? '逐字稿已生成,等你核对说话人身份后继续。'
: (byStep.transcribe?.summary || byStep.scope?.summary || '转写已完成,产物见下方与右侧产物区。')
return {
// pendingReview 让调用方(与结果卡片)知道这条流程没走完,是停在等人上。
// 不区分的话,停下等人会被显示成「已完成」—— 用户看到「完成」就不会再去点确认。
status: pendingReview ? 'pending_review' : 'ready',
pendingReview,
pendingStep: pendingReview ? 'speakers' : '',
title: `${resolvedName} 转写结果`,
summary: String(summary || '').trim() || fallbackSummary,
warnings,
highlights: [
byStep.transcribe?.summary,
byStep.speakers?.summary,
byStep.structure?.summary,
byStep.minutes?.summary,
modelName ? `转写模型:${modelName}` : '',
].filter(Boolean),
request: { plainText: resolvedName },
interactive: null,
artifacts,
speakerRoster: speakers || undefined,
artifact: {
type: 'transcript',
title: '逐字转写稿',
fileFormat: 'txt',
preview: byStep.transcribe?.content || '',
},
taskPlan: {
title: definition?.officeMeta?.taskPlanTitle || '会议音频分析(转写)流程',
steps: stepTitles.map((title, index) => ({
id: `${definition?.key || 'audio-transcribe'}-${index + 1}`,
order: index + 1,
title,
// 按**步骤名**取,不按下标取:results 里只有已经跑过的几步,
// 下标对不上,按下标会让第 3 步的说明挂到第 4 步头上。
desc: summaryByTitle[title] || '',
})),
},
}
}
@@ -5,20 +5,36 @@ import {
serializeBlocksToText,
} from './runtime'
import { buildOfficeSkillExecution } from './officeExecutionRegistry'
import { AUDIO_SKILL_KEY, executeAudioSkill } from './audioSkill'
import { executeOfficeSkill as executeOfficeSkillRequest } from '@/api/officeSkill'
// 执行器与 office 那条链路不同的技能。
//
// 只有语音转写在这里:它的产物必须由后端真的调 ASR / LLM 得到,
// /skills/office/execute 对它只会 404,而 executeOfficeSkill 的兜底会
// 静默在本地渲染出一个空壳结果 —— 那正是「看着像做完了、其实什么都没做」的由来。
// 罗列而不是判断「officeMeta.category 是不是 audio」:这里要的是**显式**声明,
// 将来加执行器时多一个 key,比多一条隐式规则更容易发现漏改。
const CUSTOM_EXECUTORS = {
[AUDIO_SKILL_KEY]: executeAudioSkill,
}
export function createOfficeSkillDefinition(manifest, resultCard) {
const key = String(manifest?.key || '').trim()
const customExecutor = CUSTOM_EXECUTORS[key]
return {
id: String(manifest?.id || `eai.${manifest?.key || 'office-skill'}`),
code: String(manifest?.code || '').trim(),
source: manifest?.source || SKILL_SOURCES.EAI,
key: String(manifest?.key || '').trim(),
key,
label: String(manifest?.label || '').trim(),
kind: manifest?.kind || 'skill',
icon: manifest?.icon || 'skill',
description: manifest?.description || manifest?.caption || '',
officeMeta: manifest?.officeMeta || {},
executor: (context = {}) => executeOfficeSkill({ ...context, definition: manifest }),
executor: customExecutor
? (context = {}) => customExecutor({ ...context, definition: manifest })
: (context = {}) => executeOfficeSkill({ ...context, definition: manifest }),
resultCard,
}
}
@@ -1,4 +1,4 @@
import { CONTENT_BLOCK_TYPES } from './types'
import { CONTENT_BLOCK_TYPES, isAudioResource } from './types'
function normalizeText(text) {
return String(text || '').replace(/\s+/g, ' ').trim()
@@ -55,10 +55,27 @@ export function buildComposerBlocks({ resource, resources, text }) {
return blocks
}
/**
* 有没有「值得发出去」的内容。
*
* 除了文本,挂上来的音频附件也算:语音转写的输入就是音频本身,
* 用户不该为了让发送键亮起来再手打一句「转写」。这正是原先的死结
* —— 挂了音频但没打字 → 发送键不亮 → 用户以为传完了,打一句「转写」才发出去,
* 而附件又从来不进技能执行链路,于是只得到一句 LLM 的自由发挥。
*
* 只放音频:文档类附件仍要有文字说明才发得出去(它们进的是聊天上下文,
* 光挂个文件后端也无从下手)。
*/
export function hasSendableContent(blocks) {
return Array.isArray(blocks) && blocks.some((block) => {
if (block?.type !== CONTENT_BLOCK_TYPES.TEXT) return false
return normalizeText(block.text).length > 0
if (block?.type === CONTENT_BLOCK_TYPES.TEXT) {
return normalizeText(block.text).length > 0
}
if (block?.type === CONTENT_BLOCK_TYPES.RESOURCE_LINK) {
// 必须有 mediaId:没有它执行器取不到音频,亮了也是白亮。
return Number(block?._meta?.mediaId) > 0 && isAudioResource({ fileFormat: block?._meta?.fileFormat })
}
return false
})
}
@@ -40,3 +40,19 @@ export const SKILL_SOURCES = Object.freeze({
EAI: 'eai',
CUSTOM: 'custom',
})
/**
* 可转写的音频格式。**只有这一份**:发送门控(runtime.js 的 hasSendableContent)
* 与语音转写执行器(audioSkill.js)都读它。两边各写一份的话,会出现
* 「按钮亮着但一点就报不支持这种格式」这类自相矛盾的界面。
*/
export const AUDIO_FILE_FORMATS = Object.freeze([
'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac', 'wma',
])
/** 一个附件是不是可转写的音频(按扩展名判,大小写与前导点都不计较)。 */
export function isAudioResource(resource) {
const raw = String(resource?.fileFormat || resource?.file_format || '').trim().toLowerCase()
const format = raw.startsWith('.') ? raw.slice(1) : raw
return AUDIO_FILE_FORMATS.includes(format)
}
@@ -18,6 +18,8 @@ function normalizeProviderLabel(provider) {
if (value === 'llamacpp') return 'llama.cpp'
if (value === 'lmuai') return 'LMUAI'
if (value === 'siliconflow') return 'SiliconFlow'
// 不映射就会把 "local_asr" 这个配置里的 token 原样显示给管理员。
if (value === 'local_asr') return '本机'
if (value === 'openrouter') return 'OpenRouter'
if (value === 'openai') return 'OpenAI'
return provider
@@ -37,6 +39,11 @@ function normalizeModelLabel(model) {
.replace(/gpt-5.4/gi, 'GPT 5.4')
.replace(/grok-4.20/gi, 'Grok 4.20')
.replace(/qwen3\.5-4b-q4_k_m\.gguf/gi, 'Qwen3.5 4B')
// 前后锚定:这条规则要能吸收 model 里的 "large-v3" 和 short_model_name 里的
// "whisper large-v3" 两种写法,又不能把别处出现的 "large-v3" 也改掉。
.replace(/^(?:whisper\s+)?large-v3$/i, 'Whisper large-v3')
.replace(/^XingChenASR-Diarize-V[\d.]+$/i, 'XingChen ASR Diarize')
.replace(/^Qwen3-ASR-[\d.]+B$/i, 'Qwen3 ASR')
.replace(/qwen2.5:7b/gi, 'Qwen2.5 7B')
.replace(/bge-m3/gi, 'BGE-M3')
.replace(/llava/gi, 'LLaVA')
@@ -49,6 +56,10 @@ function routeUsageLabel(aiRoute) {
if (routeId === 'chat_route_ollama_qwen') return '本地对话'
if (routeId === 'chat_route_ollama_qwen_fast') return '本地快聊'
if (routeId === 'embed_route_ollama_bge_m3') return '本地向量'
// 这条单独写死,是因为它的 short_route_name 恰好是 "本地"(见 GENERIC_ROUTE_NAMES)。
// 从表里删掉 "本地" 能顺手让这条显示出来,但会连带改掉所有本地 chat 路由的名称 ——
// 那是另一件事,不在这条改动的射程里。
if (routeId === 'audio_route_local_whisper') return '本地转写'
const shortRouteName = String(aiRoute?.short_route_name || '').trim()
if (!shortRouteName || GENERIC_ROUTE_NAMES.has(shortRouteName)) return ''
@@ -63,7 +74,11 @@ function routeModelLabel(aiRoute) {
export function formatAiRouteShortLabel(aiRoute) {
if (!aiRoute) return '未设置'
if (aiRoute.ai_route_id === 'chat_route_auto' || aiRoute.ai_route_id === 'embed_route_auto') {
if (
aiRoute.ai_route_id === 'chat_route_auto' ||
aiRoute.ai_route_id === 'embed_route_auto' ||
aiRoute.ai_route_id === 'audio_route_auto'
) {
return '自动'
}
@@ -80,7 +95,11 @@ export function formatAiRouteTitle(aiRoute) {
const provider = normalizeProviderLabel(aiRoute.provider)
const model = routeModelLabel(aiRoute)
if (aiRoute.ai_route_id === 'chat_route_auto' || aiRoute.ai_route_id === 'embed_route_auto') {
if (
aiRoute.ai_route_id === 'chat_route_auto' ||
aiRoute.ai_route_id === 'embed_route_auto' ||
aiRoute.ai_route_id === 'audio_route_auto'
) {
const detail = [provider, model].filter(Boolean).join(' / ')
const resolved = aiRoute.resolved_ai_route_id || ''
const suffix = detail || resolved || '自动选择当前最优可用模型'
@@ -88,7 +107,11 @@ export function formatAiRouteTitle(aiRoute) {
}
const usage = routeUsageLabel(aiRoute)
if (usage && provider && model) return `${usage} · ${provider} / ${model}`
// usage 就是 provider 时(SiliCloud 系路由的 short_route_name 常与 provider 同名)
// 不再重复一遍,否则标题读起来是「SiliconFlow · SiliconFlow / X」。
if (usage && provider && model) {
return usage === provider ? `${usage} / ${model}` : `${usage} · ${provider} / ${model}`
}
if (usage && model) return `${usage} · ${model}`
if (provider && model) return `${model} · ${provider}`
return aiRoute.description || aiRoute.ai_route_id || '未设置'
@@ -0,0 +1,693 @@
<template>
<div class="page-container netdisk-page">
<div class="page-title">团队网盘</div>
<!-- 顶部视图切换 -->
<el-tabs v-model="activeTab" class="netdisk-tabs">
<el-tab-pane label="团队网盘" name="files" />
<el-tab-pane label="回收站" name="recycle" />
<el-tab-pane v-if="isAdmin" label="待审批" name="audit" />
</el-tabs>
<!-- 团队网盘视图 -->
<div v-if="activeTab === 'files'" class="netdisk-main">
<!-- 左侧目录树 -->
<el-card shadow="never" class="folder-card">
<template #header>
<div class="card-header">
<span>目录</span>
<el-button type="primary" size="small" link @click="openNewFolder">新建</el-button>
</div>
</template>
<el-tree
:data="folderTree"
node-key="id"
:props="{ label: 'display_name', children: 'children' }"
highlight-current
:default-expanded-keys="[rootFolderId]"
@node-click="onFolderClick"
>
<template #default="{ data }">
<div class="tree-node">
<el-icon><Folder /></el-icon>
<span class="tree-label">{{ data.display_name }}</span>
</div>
</template>
</el-tree>
<el-empty v-if="!loadingFolders && folderTree.length === 0" :image-size="60" description="暂无目录" />
</el-card>
<!-- 右侧文件列表 -->
<el-card shadow="never" class="file-card">
<template #header>
<div class="card-header">
<span class="crumb">{{ breadcrumb }}</span>
<div class="file-actions">
<el-button size="small" @click="loadFiles">刷新</el-button>
<el-dropdown style="margin-right: 8px;" @command="onBulkCommand" :disabled="!selected.length">
<el-button size="small" :disabled="!selected.length">移动 ({{ selected.length }})<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="f in folders" :key="f.id" :command="f.path">{{ moveName(f) }}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-upload
:show-file-list="false"
:http-request="doUpload"
:before-upload="beforeUpload"
multiple
>
<el-button type="primary" size="small">上传文件</el-button>
</el-upload>
</div>
</div>
</template>
<el-table :data="files" border stripe style="width: 100%" v-loading="loadingFiles" @selection-change="onSelect">
<el-table-column type="selection" width="48" />
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<div class="filename-cell">
<el-icon><component :is="fileIcon(row.file_ext)" /></el-icon>
<span class="filename">{{ row.filename }}</span>
<el-tag v-if="row.status === 'pending'" size="small" type="warning" style="margin-left:6px;">待审批</el-tag>
<el-tag v-else-if="row.status === 'rejected'" size="small" type="danger" style="margin-left:6px;">已驳回</el-tag>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="80">
<template #default="{ row }">{{ row.file_ext?.toUpperCase() }}</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ fmtSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="来源" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'admin' ? 'info' : 'success'">
{{ row.source === 'admin' ? '管理员' : '员工' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="更新时间" width="170">
<template #default="{ row }">{{ fmtTime(row.updated_at || row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'approved'" type="primary" link size="small" @click="doPreview(row)">预览</el-button>
<el-button v-if="row.status === 'approved'" type="success" link size="small" @click="doDownload(row)">下载</el-button>
<el-dropdown>
<el-button type="info" link size="small">
更多<el-icon class="el-icon--right"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="doRename(row)">重命名</el-dropdown-item>
<el-dropdown-item @click="openMoveDialog(row)">移动到</el-dropdown-item>
<el-dropdown-item divided @click="doRecycle(row)">移入回收站</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingFiles && files.length === 0" description="该目录暂无文件" />
</el-card>
</div>
<!-- 回收站视图 -->
<el-card v-if="activeTab === 'recycle'" shadow="never">
<template #header>
<div class="card-header">
<span>回收站(共 {{ recycleItems.length }} 项)</span>
<el-button size="small" @click="loadRecycle">刷新</el-button>
</div>
</template>
<el-table :data="recycleItems" border stripe style="width: 100%" v-loading="loadingRecycle">
<el-table-column prop="filename" label="文件名" min-width="220" show-overflow-tooltip />
<el-table-column label="来源路径" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.folder_path }}</template>
</el-table-column>
<el-table-column label="删除时间" width="170">
<template #default="{ row }">{{ fmtTime(row.deleted_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="success" link size="small" @click="doRestore(row)">恢复</el-button>
<el-button type="danger" link size="small" @click="doPurge(row)">彻底删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingRecycle && recycleItems.length === 0" description="回收站为空" />
</el-card>
<!-- 审批视图 -->
<el-card v-if="activeTab === 'audit'" shadow="never">
<template #header>
<div class="card-header">
<span>文件审批(共 {{ auditTotal }} 条)</span>
<el-button size="small" @click="loadAudit">刷新</el-button>
</div>
</template>
<el-table :data="auditItems" border stripe style="width: 100%" v-loading="loadingAudit">
<el-table-column prop="filename" label="文件名" min-width="200" show-overflow-tooltip />
<el-table-column label="来源路径" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.folder_path }}</template>
</el-table-column>
<el-table-column label="大小" width="100">
<template #default="{ row }">{{ fmtSize(row.file_size) }}</template>
</el-table-column>
<el-table-column label="来源" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.source === 'admin' ? 'info' : 'success'">{{ row.source }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="auditStatusType(row.status)">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="上传时间" width="170">
<template #default="{ row }">{{ fmtTime(row.created_at) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'pending'">
<el-button type="success" link size="small" @click="doAudit(row, 'approve')">通过</el-button>
<el-button type="danger" link size="small" @click="doAudit(row, 'reject')">驳回</el-button>
</template>
<span v-else class="muted-text">{{ row.reject_reason || '已处理' }}</span>
</template>
</el-table-column>
</el-table>
<div class="table-pagination">
<el-pagination
v-model:current-page="auditPage"
v-model:page-size="auditSize"
:total="auditTotal"
background
layout="total, sizes, prev, pager, next"
:page-sizes="[10, 20, 50]"
@current-change="loadAudit"
@size-change="onAuditSizeChange"
/>
</div>
</el-card>
<!-- 新建文件夹弹窗 -->
<el-dialog v-model="folderVisible" title="新建文件夹" width="420px">
<el-form label-width="80px">
<el-form-item label="父目录">
<el-select v-model="newFolderParent" style="width: 100%;">
<el-option v-for="f in folders" :key="f.id" :label="f.path" :value="f.path" />
</el-select>
</el-form-item>
<el-form-item label="文件夹名">
<el-input v-model="newFolderName" placeholder="例如:市场资料" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="folderVisible = false">取消</el-button>
<el-button type="primary" @click="submitNewFolder">创建</el-button>
</template>
</el-dialog>
<!-- 移动文件弹窗 -->
<el-dialog v-model="moveVisible" title="移动到" width="420px">
<el-select v-model="moveTarget" style="width: 100%;">
<el-option v-for="f in folders" :key="f.id" :label="f.path" :value="f.path" />
</el-select>
<template #footer>
<el-button @click="moveVisible = false">取消</el-button>
<el-button type="primary" @click="submitMove">移动</el-button>
</template>
</el-dialog>
<!-- 预览弹窗 -->
<el-dialog v-model="previewVisible" :title="previewTitle" width="820px" append-to-body destroy-on-close>
<div class="preview-body">
<VideoPlayer v-if="isVideo" :src="previewSrc" :title="previewTitle" />
<iframe v-else-if="isIframePreview" :src="previewSrc" class="preview-iframe" />
<img v-else-if="isImage" :src="previewSrc" class="preview-image" />
<div v-else class="preview-fallback">
<el-empty description="该类型暂不支持在线预览,请下载查看" />
<el-button type="primary" @click="downloadPreviewTarget">下载文件</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Folder, FolderOpened, Document, Picture, VideoCamera, ArrowDown } from '@element-plus/icons-vue'
import {
listFolders, createFolder, patchFolder, deleteFolder,
listFiles, uploadNetdiskFile, previewFile, renameFile, moveFile, recycleFile,
listRecycle, restoreFile, purgeFile,
auditNetdiskList, auditNetdiskFile, downloadUrl, previewUrl,
} from '@/api/netdisk'
import { useAuthStore } from '@/store/auth'
import VideoPlayer from '@/components/VideoPlayer.vue'
const auth = useAuthStore()
const isAdmin = computed(() => auth.user?.role === 'admin')
const activeTab = ref('files')
const rootFolderId = '__root__'
// 目录
const folders = ref([])
const loadingFolders = ref(false)
const currentPath = ref('/')
const currentFolder = ref(null)
const folderTree = computed(() => buildTree(currentPath.value))
function buildTree(currentPathVal) {
// 构造根 + 当前路径下的子目录树(仅展示当前目录及其子孙)
const root = { id: rootFolderId, display_name: '/', path: '/', children: [] }
const prefix = currentPathVal === '/' ? '/' : currentPathVal + '/'
const children = folders.value.filter((f) => f.path.startsWith(prefix) && f.path !== '/')
root.children = attachTree(prefix, children)
return [root]
}
function attachTree(prefix, list) {
const children = list.filter((f) => {
const rest = f.path.slice(prefix.length)
return rest && !rest.includes('/')
})
return children.map((f) => ({
id: f.id,
display_name: f.display_name,
path: f.path,
children: attachTree(f.path + '/', list),
}))
}
const breadcrumb = computed(() => currentPath.value === '/' ? '根目录' : currentPath.value)
// 文件
const files = ref([])
const loadingFiles = ref(false)
const selected = ref([])
// 回收站
const recycleItems = ref([])
const loadingRecycle = ref(false)
// 审批
const auditItems = ref([])
const auditTotal = ref(0)
const auditPage = ref(1)
const auditSize = ref(10)
const loadingAudit = ref(false)
// 弹窗状态
const folderVisible = ref(false)
const newFolderParent = ref('/')
const newFolderName = ref('')
const moveVisible = ref(false)
const moveTarget = ref('/')
const moveFileObj = ref(null)
const previewVisible = ref(false)
const previewTitle = ref('')
const previewSrc = ref('')
const previewKind = ref('')
async function loadFolders() {
loadingFolders.value = true
try {
const res = await listFolders()
folders.value = res.data?.items || []
} catch {
folders.value = []
} finally {
loadingFolders.value = false
}
}
async function loadFiles() {
loadingFiles.value = true
try {
// 团队网盘默认展示已审批可见的文件;管理员额外看 pending
const status = isAdmin.value ? '' : 'approved'
const res = await listFiles({ path: currentPath.value, status })
files.value = res.data?.items || []
} catch {
files.value = []
} finally {
loadingFiles.value = false
}
}
function onFolderClick(data) {
if (currentPath.value === data.path) return
currentPath.value = data.path || '/'
currentFolder.value = data
loadFiles()
}
function openNewFolder() {
newFolderParent.value = currentPath.value
newFolderName.value = ''
folderVisible.value = true
}
async function submitNewFolder() {
if (!newFolderName.value.trim()) {
ElMessage.warning('请输入文件夹名')
return
}
const path = (newFolderParent.value === '/' ? '' : newFolderParent.value) + '/' + newFolderName.value.trim()
try {
await createFolder({ path })
ElMessage.success('创建成功')
folderVisible.value = false
await loadFolders()
} catch (e) {
ElMessage.error(e?.message || '创建失败')
}
}
// 上传
function beforeUpload() {
return true
}
async function doUpload({ file, onSuccess, onError }) {
try {
await uploadNetdiskFile(file, currentPath.value)
ElMessage.success(`「${file.name}」上传完成`)
onSuccess?.()
loadFiles()
} catch (e) {
ElMessage.error(e?.message || '上传失败')
onError?.(e)
}
}
// 预览 / 下载
async function doPreview(row) {
try {
const res = await previewFile(row.id)
const url = previewUrl(res.data?.stored_name || row.stored_name)
const ext = (res.data?.file_ext || row.file_ext || '').toLowerCase()
previewKind.value = ext
previewTitle.value = row.filename
previewSrc.value = url
previewVisible.value = true
} catch (e) {
ElMessage.error(e?.message || '预览失败')
}
}
const isImage = computed(() => ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(previewKind.value))
const isVideo = computed(() => ['mp4', 'mov', 'webm', 'm4v', 'mkv'].includes(previewKind.value))
const isIframePreview = computed(() => ['pdf', 'txt', 'md'].includes(previewKind.value))
function downloadPreviewTarget() {
if (previewFileRef.value) doDownload(previewFileRef.value)
}
const previewFileRef = ref(null)
async function doDownload(row) {
previewFileRef.value = row
try {
const resp = await fetch(downloadUrl(row.id), {
headers: { Authorization: `Bearer ${auth.token}` },
})
if (!resp.ok) throw new Error('下载失败')
const blob = await resp.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = row.filename
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
} catch (e) {
ElMessage.error(e?.message || '下载失败')
}
}
// 重命名 / 移动 / 删除
async function doRename(row) {
try {
const { value } = await ElMessageBox.prompt('请输入新文件名', '重命名', {
inputValue: row.filename,
inputPattern: /\S+/,
inputErrorMessage: '文件名不能为空',
})
await renameFile(row.id, value)
ElMessage.success('重命名成功')
loadFiles()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '重命名失败')
}
}
function openMoveDialog(row) {
moveFileObj.value = row
moveTarget.value = row.folder_path
moveVisible.value = true
}
async function submitMove() {
if (!moveFileObj.value) return
try {
await moveFile(moveFileObj.value.id, moveTarget.value)
ElMessage.success('移动成功')
moveVisible.value = false
loadFiles()
} catch (e) {
ElMessage.error(e?.message || '移动失败')
}
}
function moveName(f) {
return f.path
}
async function onBulkCommand(path) {
if (!selected.value.length) return
for (const row of selected.value) {
await moveFile(row.id, path)
}
ElMessage.success(`已移动 ${selected.value.length} 个文件`)
loadFiles()
}
function onSelect(rows) {
selected.value = rows
}
async function doRecycle(row) {
await ElMessageBox.confirm(`确定把「${row.filename}」移入回收站吗?`, '移入回收站', { type: 'warning' })
try {
await recycleFile(row.id)
ElMessage.success('已移入回收站')
loadFiles()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '操作失败')
}
}
// 回收站
async function loadRecycle() {
loadingRecycle.value = true
try {
const res = await listRecycle()
recycleItems.value = res.data?.items || []
} catch {
recycleItems.value = []
} finally {
loadingRecycle.value = false
}
}
async function doRestore(row) {
await ElMessageBox.confirm(`恢复「${row.filename}」?`, '恢复文件', { type: 'info' })
try {
await restoreFile(row.id)
ElMessage.success('已恢复')
loadRecycle()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '恢复失败')
}
}
async function doPurge(row) {
await ElMessageBox.confirm(`彻底删除「${row.filename}」?此操作不可恢复。`, '彻底删除', { type: 'error' })
try {
await purgeFile(row.id)
ElMessage.success('已彻底删除')
loadRecycle()
} catch (e) {
if (e !== 'cancel') ElMessage.error(e?.message || '删除失败')
}
}
// 审批
async function loadAudit() {
loadingAudit.value = true
try {
const res = await auditNetdiskList({ status: '', page: auditPage.value, size: auditSize.value })
auditItems.value = res.data?.items || []
auditTotal.value = res.data?.total || 0
} catch {
auditItems.value = []
auditTotal.value = 0
} finally {
loadingAudit.value = false
}
}
function onAuditSizeChange() {
auditPage.value = 1
loadAudit()
}
async function doAudit(row, action) {
let data = { action }
if (action === 'reject') {
try {
const { value } = await ElMessageBox.prompt('请输入驳回理由', '驳回文件', {
inputPattern: /\S+/,
inputErrorMessage: '驳回理由不能为空',
})
data.reject_reason = value
} catch {
return
}
}
try {
await auditNetdiskFile(row.id, data)
ElMessage.success(action === 'approve' ? '已通过' : '已驳回')
loadAudit()
} catch (e) {
ElMessage.error(e?.message || '审批失败')
}
}
// 工具函数
function fmtSize(bytes) {
if (!bytes && bytes !== 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`
}
function fmtTime(t) {
if (!t) return '—'
return String(t).replace('T', ' ').slice(0, 19)
}
function fileIcon(ext) {
ext = (ext || '').toLowerCase()
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(ext)) return Picture
if (['mp4', 'mov', 'webm', 'm4v', 'mkv'].includes(ext)) return VideoCamera
return Document
}
function auditStatusType(status) {
return status === 'approved' ? 'success' : status === 'rejected' ? 'danger' : 'warning'
}
async function refreshAll() {
await Promise.all([loadFolders(), loadFiles()])
}
onMounted(() => {
loadFolders()
loadFiles()
})
</script>
<style scoped>
.netdisk-page {
height: calc(100vh - 120px);
}
.netdisk-tabs {
margin-bottom: 4px;
}
.netdisk-main {
display: flex;
gap: 16px;
min-height: 400px;
}
.folder-card {
width: 260px;
flex-shrink: 0;
}
.file-card {
flex: 1;
min-width: 0;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.file-actions {
display: flex;
align-items: center;
}
.crumb {
font-weight: 600;
}
.tree-node {
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
}
.tree-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filename-cell {
display: flex;
align-items: center;
gap: 4px;
}
.filename {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-body {
max-height: 70vh;
overflow: auto;
}
.preview-iframe {
width: 100%;
height: 70vh;
border: none;
}
.preview-image {
max-width: 100%;
max-height: 70vh;
display: block;
margin: 0 auto;
}
.preview-fallback {
text-align: center;
padding: 24px;
}
.table-pagination {
margin-top: 16px;
display: flex;
justify-content: flex-start;
}
.muted-text {
color: #909399;
font-size: 13px;
}
</style>
@@ -83,6 +83,22 @@
</el-select>
</el-form-item>
<div class="route-group-label">语音</div>
<el-form-item label="语音转写">
<el-select v-model="aiConfig.agent_routes.audio_transcribe" placeholder="选择语音转写路由" style="width: 100%">
<el-option
v-for="asrRoute in aiAudioRoutes"
:key="asrRoute.ai_route_id"
:label="formatAiRouteLabel(asrRoute)"
:value="asrRoute.ai_route_id"
/>
</el-select>
<div class="route-hint">
音频转写走哪条路。默认「自动」=优先本机(音频不出网),
本机服务不可用时才回退云端;回退时转写产物里会标注「音频已出网」。
</div>
</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%">
@@ -132,6 +148,20 @@
/>
</el-select>
</el-form-item>
<el-form-item label="默认语音路由">
<el-select v-model="aiConfig.default_audio_route" placeholder="选择默认语音路由" style="width: 100%">
<el-option
v-for="asrRoute in aiAudioRoutes"
:key="asrRoute.ai_route_id"
:label="formatAiRouteLabel(asrRoute)"
:value="asrRoute.ai_route_id"
/>
</el-select>
<div class="route-hint">
出厂指向「本地」。当「语音转写」选的是「自动」时,
这里指向的那条会在健康的前提下优先胜出 —— 这就是「优先本地」的开关。
</div>
</el-form-item>
</el-form>
</div>
@@ -289,6 +319,7 @@
<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-button value="audio">语音</el-radio-button>
</el-radio-group>
<span class="filter-label">路由</span>
<el-select
@@ -315,13 +346,20 @@
</div>
<div class="test-actions">
<!-- 语音类不看提示词:测试发的是固定的一段静音样本。
留着这个输入框会让人以为填了词就会拿去转写。 -->
<el-input
v-if="testKind !== 'audio'"
v-model="testPrompt"
type="textarea"
:rows="2"
placeholder="业务测试提示词(留空使用默认测试词)"
style="flex: 1"
/>
<div v-else class="cfg-desc" style="flex: 1">
语音测试用一段 1 秒静音样本打真实转写接口,验的是终点、鉴权与返回结构;
静音转出空文本属预期,不代表路由不可用。转写质量请用真实录音跑一次「语音转写」技能。
</div>
<el-input
v-if="testKind === 'image'"
v-model="testSize"
@@ -442,12 +480,12 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAiConfig, getAiUsage, getAiUsageUsers, getRouteTestModels, getSecretsStatus, putAiConfig, reloadAiConfig, runRouteTest } from '@/api/ai'
import { listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import { listAiAudioRoutes, listAiChatRoutes, listAiEmbedRoutes } from '@/api/system'
import DonutChart from '@/components/charts/DonutChart.vue'
import LineChart from '@/components/charts/LineChart.vue'
import { formatAiRouteTitle } from '@/utils/aiRouteLabel'
const USAGE_KIND_LABEL = { ai_chat: '对话', chat_message: '对话', text_gen: '快捷动作', embed: '检索' }
const USAGE_KIND_LABEL = { ai_chat: '对话', chat_message: '对话', text_gen: '快捷动作', embed: '检索', audio_transcribe: '语音转写' }
const USAGE_KIND_COLORS = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399']
const activeTab = ref('routes')
@@ -457,8 +495,9 @@ const savingAi = ref(false)
const reloading = ref(false)
const aiChatRoutes = ref([])
const aiEmbedRoutes = ref([])
const aiAudioRoutes = ref([])
const secrets = ref({})
const aiConfig = reactive({ agent_routes: {}, default_route: '', default_embed_route: '' })
const aiConfig = reactive({ agent_routes: {}, default_route: '', default_embed_route: '', default_audio_route: '' })
const aiConfigText = ref('')
const days = ref(30)
@@ -523,24 +562,28 @@ function bucketLabel(value) {
async function loadRoutes() {
routeLoading.value = true
try {
const [cfgRes, secretRes, chatRes, embedRes] = await Promise.all([
const [cfgRes, secretRes, chatRes, embedRes, audioRes] = await Promise.all([
getAiConfig(),
getSecretsStatus(),
listAiChatRoutes().catch(() => ({ data: { routes: [] } })),
listAiEmbedRoutes().catch(() => ({ data: { routes: [] } })),
listAiAudioRoutes().catch(() => ({ data: { routes: [] } })),
])
const cfg = cfgRes.data || {}
aiConfig.agent_routes = { ...(cfg.agent_routes || {}) }
aiConfig.default_route = cfg.default_route || ''
aiConfig.default_embed_route = cfg.default_embed_route || ''
aiConfig.default_audio_route = cfg.default_audio_route || ''
aiConfigText.value = JSON.stringify(cfg, null, 2)
secrets.value = secretRes.data?.providers || {}
aiChatRoutes.value = chatRes.data?.routes || []
aiEmbedRoutes.value = embedRes.data?.routes || []
aiAudioRoutes.value = audioRes.data?.routes || []
} catch (e) {
secrets.value = {}
aiChatRoutes.value = []
aiEmbedRoutes.value = []
aiAudioRoutes.value = []
} finally {
routeLoading.value = false
syncTestRoutes()
@@ -560,6 +603,7 @@ async function saveAi() {
config.agent_routes = { ...(config.agent_routes || {}), ...aiConfig.agent_routes }
config.default_route = aiConfig.default_route
config.default_embed_route = aiConfig.default_embed_route
config.default_audio_route = aiConfig.default_audio_route
await putAiConfig(config)
aiConfigText.value = JSON.stringify(config, null, 2)
ElMessage.success('AI 配置已保存并热生效')
@@ -644,6 +688,8 @@ function onTestKindChange() {
testRouteOptions.value = aiChatRoutes.value || []
} else if (testKind.value === 'embed') {
testRouteOptions.value = aiEmbedRoutes.value || []
} else if (testKind.value === 'audio') {
testRouteOptions.value = aiAudioRoutes.value || []
} else {
testRouteOptions.value = imageTestRoutesFromConfig()
}
@@ -750,7 +796,7 @@ 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 key = testKind.value === 'image' ? 'image_routes' : testKind.value === 'embed' ? 'embed_routes' : testKind.value === 'audio' ? 'audio_routes' : 'chat_routes'
const routes = cfg[key]
if (!routes || !routes[selectedRouteId.value] || Array.isArray(routes[selectedRouteId.value])) {
ElMessage.error('未在配置中找到该路由,无法写回 model')
@@ -765,10 +811,11 @@ function applyModel(row) {
}
}
// 同步通路测试路由选项(加载 chat/embed 路由后调用)
// 同步通路测试路由选项(加载 chat/embed/audio 路由后调用)
function syncTestRoutes() {
if (testKind.value === 'chat') testRouteOptions.value = aiChatRoutes.value || []
else if (testKind.value === 'embed') testRouteOptions.value = aiEmbedRoutes.value || []
else if (testKind.value === 'audio') testRouteOptions.value = aiAudioRoutes.value || []
}
async function loadCharts() {
@@ -930,6 +977,15 @@ onMounted(() => {
padding: 4px 0 0 0;
}
/* 下拉框下面的说明行。用 el-form-item 的默认行距会把提示推得离控件很远,
这里紧贴控件下方。 */
.route-hint {
font-size: 12px;
line-height: 1.6;
color: #909399;
margin-top: 4px;
}
.test-pagination {
display: flex;
justify-content: flex-end;
@@ -26,7 +26,7 @@
hero-input
:show-panel="showSpecialistPanel"
:show-strip="showTopStrip"
:has-messages="messages.length > 0"
:has-messages="messages.length > 0 || Boolean(pendingSpeakerConfirm)"
:active-nav-item="activeNavItem"
:history-count="5"
:task-items="taskItems"
@@ -153,6 +153,17 @@
<div class="msg-time">{{ msg.timestamp }}</div>
</div>
</div>
<!-- 待确认的说话人身份。它挂在消息列表**外面**,因为它的内容是从
当前任务的状态推出来的,不是某一条消息的属性:消息里的 skillResult
不落库(serializeMessage 只存 role/content/timestamp 等),
刷新一次就没了,而「这段录音里谁是谁」是不能刷新一次就问一次的。 -->
<AudioSpeakerConfirmCard
v-if="pendingSpeakerConfirm"
:speakers="pendingSpeakerConfirm.speakers"
:busy="speakerConfirmBusy"
@confirm="handleSpeakerConfirm"
/>
<!-- Typing indicator -->
<div v-if="sending" class="chat-message assistant">
<div class="msg-avatar assistant">🤖</div>
@@ -202,6 +213,7 @@ import { useRoute, useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { ElMessage } from 'element-plus'
import { sendChatMessage } from '@/api/chatMessage'
import http from '@/api/http'
import {
createWeixinPublicAccountTask,
executeWeixinPublicAccountWorkflowStep,
@@ -224,6 +236,14 @@ import { useAiRouteStore } from '@/store/llmRoute'
import { useWorkbenchStore } from '@/store/workbench'
import { getSkillByKey } from '@/skills/registry'
import { buildComposerBlocks, blocksToMountedResources, hasSendableContent } from '@/skills/shared/runtime'
import {
AUDIO_SKILL_KEY,
pickAudioResource,
resumeAudioSkill,
runAudioSkillStep,
skillConsumesAudio,
} from '@/skills/shared/audioSkill'
import AudioSpeakerConfirmCard from '@/components/chat/AudioSpeakerConfirmCard.vue'
const route = useRoute()
const router = useRouter()
@@ -237,6 +257,7 @@ const {
currentSpecialist,
currentSpecialistKey,
currentTaskId,
currentTaskDetail,
} = storeToRefs(taskRuntime)
// 模式的值存在技能设置里,不在这个页面自己手上 ——
@@ -291,6 +312,19 @@ const defaultGeneralSpecialistPresentation = Object.freeze({
meta: '默认专员 · 通用协作',
})
// 通用助手开场白:优先用后端 /api/assistant/opening 的单一事实源,
// 请求失败时才落到上面的静态兜底(避免空白欢迎画面)。
const generalAssistantOpening = ref(null)
async function fetchGeneralAssistantOpening() {
try {
const res = await http.get('/assistant/opening')
if (res?.data) generalAssistantOpening.value = res.data
} catch (error) {
console.warn('加载通用助手开场白失败', error)
}
}
const messages = ref([])
const conversationBootstrapped = ref(false)
const mountedRegistrySkill = computed(() => {
@@ -368,11 +402,27 @@ const currentSkillPresentation = computed(() => {
}
})
const currentObjectPresentation = computed(() => (
currentFocusedObjectKind.value === 'specialist'
const currentObjectPresentation = computed(() => {
const base = currentFocusedObjectKind.value === 'specialist'
? (currentSpecialistPresentation.value || currentSkillPresentation.value || defaultGeneralSpecialistPresentation)
: (currentSkillPresentation.value || currentSpecialistPresentation.value || defaultGeneralSpecialistPresentation)
))
// 通用助手(落在 defaultGeneralSpecialistPresentation)时,用后端开场白单一事实源覆盖。
if (base === defaultGeneralSpecialistPresentation && generalAssistantOpening.value) {
const o = generalAssistantOpening.value
return {
...base,
label: o.label || base.label,
greeting: o.greeting || base.greeting,
tagline: o.tagline || base.tagline,
openingPrompt: o.opening_prompt || base.openingPrompt,
starterPrompts: (o.starter_prompts && o.starter_prompts.length)
? o.starter_prompts
: base.starterPrompts,
meta: o.meta || base.meta,
}
}
return base
})
const welcomeKicker = computed(() => currentObjectPresentation.value.meta)
const welcomeTitle = computed(() => currentObjectPresentation.value.greeting)
@@ -407,7 +457,20 @@ const composerBlocks = computed(() => buildComposerBlocks({
text: inputText.value,
}))
const canSendContent = computed(() => hasSendableContent(composerBlocks.value) && !sending.value)
// 只挂了音频、没打字,而当前技能并不消费音频。
//
// hasSendableContent 是纯数据判断(「块里有没有内容」),它不知道此刻挂着什么技能,
// 而音频附件只对语音转写那类执行器有意义 —— 附件不进聊天请求体,普通聊天拿它没办法。
// 这一层把上下文补上:否则在通用助手里挂个音频,发送键亮着,点下去却发不出去。
const audioNeedsSkill = computed(() => (
!inputText.value.trim()
&& Boolean(pickAudioResource(composerUploads.value))
&& !skillConsumesAudio(currentSkillKey.value)
))
const canSendContent = computed(
() => hasSendableContent(composerBlocks.value) && !audioNeedsSkill.value && !sending.value,
)
const taskItems = ref([
{ id: '1', title: '2026 当中 GPU - 显...' },
@@ -467,53 +530,136 @@ function renderInlineMarkdown(text) {
return html
}
// 行级 markdown 渲染:逐行判定类型,把相邻同类型行归为一组,输出对应块级标签。
// 相比"整块必须是单一类型"的旧实现,这里能正确处理真实 LLM 输出的
// 「标题 + 列表项 + 段落 + 表格」混排(无空行分隔、且标题后紧跟列表等场景)。
function renderMarkdownBlock(block) {
const trimmed = String(block || '').trim()
if (!trimmed) return ''
const headingMatch = trimmed.match(/^(#{1,3})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
const heading = `<h${level}>${renderInlineMarkdown(headingMatch[2])}</h${level}>`
// If the block has more lines after the heading, render them as additional content
const afterHeading = trimmed.split('\n', 2)[1]
if (afterHeading && afterHeading.trim()) {
return heading + ' ' + renderMarkdownBlock(afterHeading.trim())
}
return heading
}
const lines = trimmed.split('\n')
if (lines.every((line) => /^\s*>\s?/.test(line))) {
const quote = lines.map((line) => line.replace(/^\s*>\s?/, '')).join('<br>')
return `<blockquote>${renderInlineMarkdown(quote)}</blockquote>`
let html = ''
let paragraph = []
const flushParagraph = () => {
if (paragraph.length) {
html += `<p>${paragraph.map(renderInlineMarkdown).join('<br>')}</p>`
paragraph = []
}
}
if (lines.every((line) => /^\s*[-*]\s+/.test(line))) {
const items = lines
.map((line) => line.replace(/^\s*[-*]\s+/, ''))
.map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
.join('')
return `<ul>${items}</ul>`
}
let i = 0
while (i < lines.length) {
const line = lines[i]
if (lines.every((line) => /^\s*\d+\.\s+/.test(line))) {
const items = lines
.map((line) => line.replace(/^\s*\d+\.\s+/, ''))
.map((line) => `<li>${renderInlineMarkdown(line)}</li>`)
.join('')
return `<ol>${items}</ol>`
}
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/)
if (headingMatch) {
flushParagraph()
html += `<h${headingMatch[1].length}>${renderInlineMarkdown(headingMatch[2])}</h${headingMatch[1].length}>`
i++
continue
}
return `<p>${lines.map((line) => renderInlineMarkdown(line)).join('<br>')}</p>`
const hrMatch = line.match(/^([-*_]\s*){3,}$/)
if (hrMatch) {
flushParagraph()
html += '<hr>'
i++
continue
}
// 表格:以 | 开头,且下面紧跟 | -- | 分隔行
if (/^\s*\|/.test(line) && i + 1 < lines.length && /^\s*\|?[\s\-:|]+\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) {
flushParagraph()
const headerCells = line.split('|').slice(1, -1).map((c) => c.trim())
html += '<table><thead><tr>' + headerCells.map((c) => `<th>${renderInlineMarkdown(c)}</th>`).join('') + '</tr></thead><tbody>'
i += 2
while (i < lines.length && /^\s*\|/.test(lines[i])) {
const cells = lines[i].split('|').slice(1, -1).map((c) => c.trim())
html += '<tr>' + cells.map((c) => `<td>${renderInlineMarkdown(c)}</td>`).join('') + '</tr>'
i++
}
html += '</tbody></table>'
continue
}
// 无序列表
const ulMatch = line.match(/^\s*[-*+]\s+(.+)$/)
if (ulMatch) {
flushParagraph()
const items = []
while (i < lines.length) {
const m = lines[i].match(/^\s*[-*+]\s+(.+)$/)
if (!m) break
items.push(`<li>${renderInlineMarkdown(m[1])}</li>`)
i++
}
html += `<ul>${items.join('')}</ul>`
continue
}
// 有序列表
const olMatch = line.match(/^\s*\d+\.\s+(.+)$/)
if (olMatch) {
flushParagraph()
const items = []
while (i < lines.length) {
const m = lines[i].match(/^\s*\d+\.\s+(.+)$/)
if (!m) break
items.push(`<li>${renderInlineMarkdown(m[1])}</li>`)
i++
}
html += `<ol>${items.join('')}</ol>`
continue
}
// 引用
const quoteMatch = line.match(/^\s*>\s?(.*)$/)
if (quoteMatch) {
flushParagraph()
const items = []
while (i < lines.length) {
const m = lines[i].match(/^\s*>\s?(.*)$/)
if (!m) break
items.push(renderInlineMarkdown(m[1]))
i++
}
html += `<blockquote>${items.join('<br>')}</blockquote>`
continue
}
// 普通文本行:先攒进段落,遇到空行或下一类块级元素时统一输出
paragraph.push(line)
i++
}
flushParagraph()
return html
}
function renderMarkdownToHtml(text) {
const raw = String(text || '').replace(/\r\n/g, '\n').trim()
if (!raw) return ''
// 先抽出 <details>…</details> 这类原生 HTML 容器,保留原生标签,但内部内容继续走
// 下方 markdown 渲染,否则整套 block 渲染器会把尖括号转义成 &lt;details&gt;,
// 折叠块就变成一坨字面量文本,用户看不到「分 agent」提示。
const detailBlocks = []
const withoutDetails = raw.replace(/<details>([\s\S]*?)<\/details>/g, (full, inner) => {
const token = `@@DETAILS_BLOCK_${detailBlocks.length}@@`
const summaryMatch = inner.match(/<summary>([\s\S]*?)<\/summary>/)
const summaryHtml = summaryMatch
? `<summary>${renderInlineMarkdown(summaryMatch[1])}</summary>`
: ''
const body = inner
.replace(/<summary>([\s\S]*?)<\/summary>/, '')
.trim()
detailBlocks.push(`<details>${summaryHtml}${renderMarkdownToHtml(body)}</details>`)
return token
})
const codeBlocks = []
const withPlaceholders = raw.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const withPlaceholders = withoutDetails.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_, lang, code) => {
const token = `@@CODE_BLOCK_${codeBlocks.length}@@`
const languageClass = String(lang || '').trim() ? ` class="language-${escapeHtml(lang.trim())}"` : ''
codeBlocks.push(`<pre><code${languageClass}>${escapeHtml(code.trim())}</code></pre>`)
@@ -526,6 +672,7 @@ function renderMarkdownToHtml(text) {
const normalized = String(block || '').trim()
if (!normalized) return ''
if (/^@@CODE_BLOCK_\d+@@$/.test(normalized)) return normalized
if (/^@@DETAILS_BLOCK_\d+@@$/.test(normalized)) return normalized
return renderMarkdownBlock(normalized)
})
.filter(Boolean)
@@ -534,6 +681,9 @@ function renderMarkdownToHtml(text) {
codeBlocks.forEach((blockHtml, index) => {
html = html.replace(`@@CODE_BLOCK_${index}@@`, blockHtml)
})
detailBlocks.forEach((blockHtml, index) => {
html = html.replace(`@@DETAILS_BLOCK_${index}@@`, blockHtml)
})
return html
}
@@ -1221,7 +1371,18 @@ async function continueWeixinPublicAccountConversation(text, onProgress = () =>
}
async function send() {
if (!inputText.value.trim() || sending.value) return
// 这条要排在下面的早退**前面**:它本身就让 canSendContent 为假,
// 放后面就是永远走不到的死代码。它主要给回车用 —— 回车不经过按钮,
// 不提示的话按下去毫无反应(旧代码也这样,但那时发送键是暗的,用户知道原因)。
if (audioNeedsSkill.value) {
ElMessage.warning('挂着的音频要配「会议音频分析(转写)」技能才用得上;或者打一句说明再发。')
return
}
// 判据必须和发送键用的是同一个(canSendContent)—— 原先是「有没有打字」,
// 于是挂了音频但没打字时发送键亮着、点下去却什么也不发生。
// 语音转写的输入就是音频本身,用户不该为了让按钮真的能用再手打一句「转写」。
if (!canSendContent.value) return
const text = inputText.value
@@ -1371,6 +1532,27 @@ async function send() {
inputText.value = ''
sending.value = true
// 收到任务先回一句受理话术,并显示实时等待秒数——编排类任务(单问搜索、
// 多 Agent 拆解)往往要跑几十秒,用户需要先看到「已接单」的即时反馈。
const progressMessage = {
role: 'assistant',
content: '收到,正在为您处理...',
timestamp: new Date().toLocaleTimeString(),
}
let progressBaseText = progressMessage.content
const progressStartedAt = Date.now()
const syncProgressMessage = () => {
const elapsed = Math.max(0, Math.floor((Date.now() - progressStartedAt) / 1000))
progressMessage.content = elapsed >= 3
? `${progressBaseText}(已等待 ${elapsed} 秒)`
: progressBaseText
progressMessage.timestamp = new Date().toLocaleTimeString()
}
const progressTicker = window.setInterval(syncProgressMessage, 1000)
messages.value.push(progressMessage)
await nextTick()
scrollToEnd()
try {
if (mountedRegistrySkill.value?.executor) {
const result = await mountedRegistrySkill.value.executor({
@@ -1378,7 +1560,17 @@ async function send() {
inputText: text,
mode: mode.value,
taskId: currentTaskId.value,
// 音频附件要真的进执行链路,否则语音转写只能拿到一句空文本 ——
// 这正是原先「挂了音频却只得到一句 LLM 自由发挥」的原因:
// mediaId 一路都在(composerUploads / 块的 _meta 里都有),只是没人消费。
mediaId: pickAudioResource(composerUploads.value)?.mediaId || 0,
language: skillSettings.valueOf(currentSkillKey.value, 'language'),
aiRouteId: selectedAiChatRouteId.value,
// 每跑完一步刷一次右栏。转写这类技能一步就要几十秒到几分钟,
// 只在全部跑完后刷一次的话,中间右栏一直停在 0/4,看起来像卡死。
onStepDone: refreshTaskRail,
})
window.clearInterval(progressTicker)
messages.value.push(buildLocalSkillReply(result, mountedRegistrySkill.value))
if (currentTaskId.value) {
await taskRuntime.loadTaskDetail(currentTaskId.value)
@@ -1396,17 +1588,171 @@ async function send() {
})
const reply = res.data.message
reply.timestamp = new Date().toLocaleTimeString()
window.clearInterval(progressTicker)
messages.value.push(reply)
}
await nextTick()
scrollToEnd()
} catch (error) {
window.clearInterval(progressTicker)
// 失败也必须刷一次右栏。前几步的 run 与产物已经落库了,不刷的话右栏连
// 「前两步做完了、产物在这」都不显示,用户会把一次部分成功当成全部失败,
// 而错误信息里那句「前 N 步的产物已保存」也就无从对照。
await refreshTaskRail()
progressMessage.content = `执行失败:${error?.message || '请稍后重试'}`
progressMessage.timestamp = new Date().toLocaleTimeString()
ElMessage.error(error.message || '发送失败')
} finally {
window.clearInterval(progressTicker)
sending.value = false
}
}
/**
* 重新拉一次当前任务详情,让右栏的工作流进度与产物区跟上。
*
* 刷新失败只记一条警告,不往上抛:那一刻产物已经落库了,把异常冒出去会把
* 一次成功的执行报成失败。用户下次切回这个任务时会重新拉到。
*/
async function refreshTaskRail() {
if (!currentTaskId.value) return
try {
await taskRuntime.loadTaskDetail(currentTaskId.value)
} catch (error) {
console.warn('refresh task rail failed', error)
}
}
// ===== 说话人身份的待确认卡片 =====
const speakerConfirmBusy = ref(false)
/**
* 当前是否有「推断出来的说话人身份,等人点头」。
*
* 从**任务状态**推,不从消息推:
* - 消息里的 skillResult 不落库,刷新一次就没了,而这件事不能刷新一次问一次;
* - 状态是后端的,前端算出来的「我觉得该确认了」和后端的闸门可能不一致 ——
* 以任务状态为准,卡片在不在就和「第 5 步跑不跑得动」是同一个判据。
*
* 三个条件缺一不可:任务停在「待确认」、存在说话人名单产物、且那份名单还没被确认。
*/
const pendingSpeakerConfirm = computed(() => {
const detail = currentTaskDetail.value
if (!detail) return null
if (String(detail?.task?.status || '').trim() !== '待确认') return null
const artifacts = Array.isArray(detail.artifacts) ? detail.artifacts : []
// 取最新的一份:重新识别过的话,要确认的是新那版。
const listArtifact = [...artifacts]
.filter((item) => String(item?.artifact_type || item?.artifactType || '').trim() === 'speakers')
.sort((a, b) => Number(b?.id || 0) - Number(a?.id || 0))[0]
if (!listArtifact) return null
if (String(listArtifact.status || '').trim() !== 'ready') return null
const speakers = speakerRosterFromArtifact(listArtifact)
if (!speakers.length) return null
return {
artifactId: listArtifact.id,
taskId: detail?.task?.id || currentTaskId.value,
speakers,
}
})
/** 从「说话人名单」产物里取出名单。产物正文是给人看的,结构化那份在 content_json 里。 */
function speakerRosterFromArtifact(artifact) {
const source = artifact?.content_json ?? artifact?.contentJson
if (!source) return []
let payload = source
if (typeof source === 'string') {
try {
payload = JSON.parse(source)
} catch {
return []
}
}
const list = payload?.speakers
return Array.isArray(list) ? list : []
}
/**
* 用户确认名单 → 落库 → 接着跑「整理段落与重点」与「提炼可交付纪要」。
*
* 确认与继续是**两次请求**而不是一次:确认那一下必须独立成功。若合成一次,
* 后面两步跑失败会连带让用户以为「我确认的这个动作也没成」,于是再点一次,
* 而那时名单已经是已确认状态、会被后端按重复提交拒掉。
*/
async function handleSpeakerConfirm(speakers) {
if (speakerConfirmBusy.value) return
const taskId = Number(currentTaskId.value) || 0
if (!taskId) {
ElMessage.error('找不到当前任务,请刷新后重试')
return
}
speakerConfirmBusy.value = true
try {
await runAudioSkillStep('speakers-confirm', { task_id: taskId, speakers })
await refreshTaskRail()
} catch (error) {
ElMessage.error(`确认失败:${error?.message || '请稍后重试'}`)
speakerConfirmBusy.value = false
return
}
// 确认已经落库了,从这里往后就是「继续跑」—— 失败也不能把上面那次确认说成失败。
const pendingMessage = {
role: 'assistant',
content: '已记下你确认的说话人身份,正在继续整理段落与生成纪要...',
timestamp: new Date().toLocaleTimeString(),
}
messages.value.push(pendingMessage)
await nextTick()
scrollToEnd()
try {
const result = await resumeAudioSkill({
// 按 key 从注册表取,不用 mountedRegistrySkill:刷新页面之后
// currentSkillKey 会退回默认技能,那个 computed 拿到的就不是转写技能了,
// 结果卡上的流程标题会跟着串味。
definition: getSkillByKey(AUDIO_SKILL_KEY) || mountedRegistrySkill.value,
taskId,
aiRouteId: selectedAiChatRouteId.value,
// scope 那一步的产物不在这一轮的 results 里(它是确认之前跑的),
// 用它生成文件名下载才不至于退回默认的「转写稿」。
fileName: audioFileNameOf(currentTaskDetail.value),
onStepDone: refreshTaskRail,
})
messages.value.push(buildLocalSkillReply(result, mountedRegistrySkill.value))
} catch (error) {
pendingMessage.content = `已确认说话人身份,但继续执行失败:${error?.message || '请稍后重试'}`
pendingMessage.timestamp = new Date().toLocaleTimeString()
ElMessage.error(error.message || '继续执行失败')
} finally {
speakerConfirmBusy.value = false
await refreshTaskRail()
await nextTick()
scrollToEnd()
}
}
/** 从任务产物里找出这次转写的音频文件名(用于下载名)。取不到就交给调用方兜底。 */
function audioFileNameOf(detail) {
const artifacts = Array.isArray(detail?.artifacts) ? detail.artifacts : []
const scope = [...artifacts]
.filter((item) => String(item?.artifact_type || item?.artifactType || '').trim() === 'text')
.sort((a, b) => Number(a?.id || 0) - Number(b?.id || 0))[0]
const source = scope?.content_json ?? scope?.contentJson
if (!source) return ''
try {
const payload = typeof source === 'string' ? JSON.parse(source) : source
return String(payload?.file_name || '').trim()
} catch {
return ''
}
}
function scrollToEnd() {
const chatBody = document.querySelector('.chat-body')
if (chatBody) {
@@ -1668,10 +2014,70 @@ watch(
{ deep: true },
)
/**
* 重开一条旧任务时,把「这条任务是哪个技能做的」从库里捡回来。
*
* 为什么需要:当前技能只按任务挂在内存里(taskSkillKeyById),刷新即丢。
* 但这件事**本来就落在库里** —— 每一步的 task_run.action_key 就是技能 key
* (多步技能写成 `技能key:步骤`)。丢的是前端的内存,不是事实。
* 捡不回来,右栏就在重开任务时整块消失 —— 正在等人确认的转写任务会看起来
* 像从没做过。
*
* 只在「这条任务在前端还没记住任何技能」时补:用户刚在 + 里选的技能说了算,
* 被这里的推断盖掉的话,「先选技能再点开任务」就会失效。
*
* 认技能要用 skillCatalog,而它由 onMounted 里的 hydrate() 拉回来。挂载那一瞬间
* 就到这里的调用会认不出来(目录还空着),代价只是右栏晚一次点击才出现,
* 所以不为此加锁;也不要改成 await hydrate() 假装稳妥 —— hydrate 在
* 「已经在加载中」时直接 return,await 它换来的是立刻返回,不是等它加载完。
*/
function restoreSkillFromTaskRuns(taskId, detail) {
const id = String(taskId || '')
if (!id) return
if (taskRuntime.skillKeyOfTask(id)) return
const runs = Array.isArray(detail?.runs) ? detail.runs : []
for (const run of runs) {
const key = String(run?.action_key || '').trim().split(':')[0]
if (!key || key === HOME_SKILL) continue
// 认不出来就不认。公众号那几步的 action_key 是**裸步骤名**(content_creation、
// title_generation),不是技能 key;照单全收会把一个不存在的技能设成当前技能,
// 右栏拿不到 blueprint 就整块空掉 —— 比不恢复更糟。
if (!skillCatalog.getByKey(key)) continue
taskRuntime.attachSkillToCurrentTask(key)
return
}
}
// 当前任务一变就把它的详情拉回来。
//
// 不能只靠右栏(SpecialistPanel)去拉:它挂在 showSpecialistPanel 后面,而那个
// 条件依赖「当前技能/专员不是默认的那个」,重开一条通用专员名下的任务时整块不渲染,
// 于是没人拉详情,待确认的说话人名单也就跟着不见了 —— 而它恰恰是在这种时候
// 最需要露出来。所以这一份数据由页面自己保证。
watch(
currentTaskId,
async (nextId) => {
const taskId = String(nextId || '').trim()
if (!taskId) return
let detail = null
try {
detail = await taskRuntime.loadTaskDetail(taskId)
} catch (error) {
// 拉不到只是右栏和确认卡片空着,不该把重开任务这件事整个报失败。
console.warn('load task detail failed', error)
return
}
restoreSkillFromTaskRuns(taskId, detail)
},
{ immediate: true },
)
onMounted(async () => {
await Promise.all([
skillCatalog.hydrate(),
specialistCatalog.hydrate(),
fetchGeneralAssistantOpening(),
])
await taskRuntime.loadMyTasks()
const routeHasPreset = Boolean(