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:
@@ -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 '出错'
|
||||
|
||||
@@ -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 渲染器会把尖括号转义成 <details>,
|
||||
// 折叠块就变成一坨字面量文本,用户看不到「分 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(
|
||||
|
||||
Reference in New Issue
Block a user