feat(frontend): 接入真实媒体工作流与模型配置卡片

This commit is contained in:
2026-09-04 12:39:50 +08:00
parent 8d092533f6
commit 8c644d0aae
15 changed files with 428 additions and 13 deletions
+151
View File
@@ -0,0 +1,151 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { mediaService, type MediaJob } from '@/services/mediaService'
const route = useRoute()
const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null)
const file = ref<File | null>(null)
const reference = ref<File | null>(null)
const matchResult = ref('')
const localOnly = ref(false)
const diarization = ref(true)
const terminology = ref('')
const busy = ref(false)
const error = ref('')
const notice = ref('')
const dirty = ref(false)
const title = ref('课堂转写')
const player = ref<HTMLAudioElement | null>(null)
const position = ref(0)
const speed = ref(1)
const history = ref<MediaJob[]>([])
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
const labels = {queued: '排队中', running: '转写中', processing: '处理中', completed: '已完成', failed: '失败', cancelled: '已取消'}
const speakers = computed(() => [...new Set(selected.value?.segments.map(s => s.speaker).filter((s): s is string => !!s) || [])])
const active = (job: MediaJob) => ['queued', 'running', 'processing'].includes(job.status)
const stamp = (seconds: number) => `${Math.floor(seconds / 60).toString().padStart(2, '0')}:${Math.floor(seconds % 60).toString().padStart(2, '0')}`
async function refresh() {
try {
jobs.value = (await mediaService.list()).items
if (selected.value && !dirty.value) selected.value = jobs.value.find(j => j.job_id === selected.value?.job_id) || selected.value
} catch (e) { error.value = (e as Error).message }
if (!stopped) timer = setTimeout(refresh, 2000)
}
async function choose(job: MediaJob) {
if (dirty.value && !window.confirm('当前校对尚未保存,切换后放弃修改?')) return
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
}
async function action(work: () => Promise<void>) {
busy.value = true; error.value = ''; notice.value = ''
try { await work() } catch (e) { error.value = (e as Error).message } finally { busy.value = false }
}
async function submit() {
if (!file.value) return
await action(async () => {
let terms = {}
if (terminology.value.trim()) {
terms = JSON.parse(terminology.value)
if (!terms || typeof terms !== 'object' || Array.isArray(terms) || Object.values(terms).some(v => typeof v !== 'string')) throw new Error('术语表需要 JSON 对象,值为替换后的文本。')
}
const uploaded = await mediaService.upload(file.value!)
selected.value = await mediaService.create({attachment_id: uploaded.attachment_id, local_only: localOnly.value,
diarization: diarization.value, idempotency_key: crypto.randomUUID(), terminology: terms})
dirty.value = false
jobs.value.unshift(selected.value)
})
}
function seek(seconds: number) { if (player.value) { player.value.currentTime = seconds; position.value = seconds } }
async function purge() {
if (!selected.value) return
await action(async () => {
const impact = await mediaService.impact(selected.value!.attachment_id)
if (!window.confirm(`${impact.message}\n将保留 ${impact.retained_note_ids.length} 篇已保存笔记。确定清理?`)) return
await mediaService.purge(selected.value!.attachment_id)
selected.value = await mediaService.get(selected.value!.job_id)
dirty.value = false; history.value = []; notice.value = '附件与转写内容已清理'
})
}
async function compareSpeaker() {
if (!file.value || !reference.value) return
await action(async () => {
const temporary: string[] = []
try {
const sample = await mediaService.upload(file.value!); temporary.push(sample.attachment_id)
const known = await mediaService.upload(reference.value!); temporary.push(known.attachment_id)
const result = await mediaService.match(sample.attachment_id, known.attachment_id, localOnly.value)
matchResult.value = `相似度 ${result.score.toFixed(3)} · ${result.source === 'local' ? '本地模型' : 'API'}${result.fallback_reason ? ` · 回退:${result.fallback_reason}` : ''}`
} finally {
const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id)))
if (cleanup.some(result => result.status === 'rejected')) notice.value = '部分临时参考附件清理失败,请检查后端连接。'
}
})
}
function loaded() { if (player.value) player.value.playbackRate = speed.value; const seconds = Number(route.query.time || 0); if (Number.isFinite(seconds) && seconds >= 0) seek(seconds) }
onMounted(async () => {
await refresh()
if (typeof route.query.job === 'string') {
try { selected.value = await mediaService.get(route.query.job) } catch (e) { error.value = (e as Error).message }
}
})
onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script>
<template>
<section class="media-page">
<header><h1>音视频转写</h1><p class="subtle">上传音频或视频音轨转写校对后保存到知识库单个文件最多 25 MiB</p></header>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div><p v-if="notice" role="status">{{ notice }}</p>
<form class="panel upload" @submit.prevent="submit">
<label>选择附件<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @change="file = ($event.target as HTMLInputElement).files?.[0] || null" /></label>
<label><input v-model="localOnly" type="checkbox" />仅本地处理</label>
<label><input v-model="diarization" type="checkbox" />识别不同说话人</label>
<p class="subtle">{{ localOnly ? '本次任务不调用远程模型 API,模型需预先下载。' : '若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。' }}</p>
<details><summary>术语校对</summary><p class="subtle">在识别完成后替换文本原始识别结果会保留</p><textarea v-model="terminology" class="input" rows="3" placeholder='{"错误术语": "正确术语"}' /></details>
<button class="button-primary" :disabled="busy || !file">{{ busy ? '处理中…' : '上传并转写' }}</button>
<details><summary>声纹参考比对</summary><p class="subtle">将所选附件与参考音频比对至少各含 1 秒语音分数是相似度不是身份认证概率临时参考文件在比对后清理</p>
<input type="file" accept=".wav,.mp3,.flac,.ogg,.m4a" aria-label="声纹参考音频" @change="reference = ($event.target as HTMLInputElement).files?.[0] || null" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">比对声纹</button><p v-if="matchResult">{{ matchResult }}</p></details>
</form>
<div class="media-columns">
<aside class="panel"><h2>转写任务</h2><p v-if="!jobs.length" class="subtle">暂无转写任务</p>
<button v-for="job in jobs" :key="job.job_id" class="job-row" :class="{ selected: selected?.job_id === job.job_id }" @click="choose(job)">
<strong>{{ labels[job.status] }}</strong><span>{{ new Date(job.created_at).toLocaleString() }}</span><small>{{ job.attachment_id }}</small>
</button>
</aside>
<article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">修订 {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" aria-label="转写进度" />
<audio ref="player" controls :src="mediaService.audio(selected.attachment_id)" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<label>播放速度<select v-model.number="speed" class="select" @change="player && (player.playbackRate = speed)"><option v-for="value in [0.5, 0.75, 1, 1.25, 1.5, 2]" :key="value" :value="value">{{ value }}×</option></select></label>
<p v-if="selected.error_message" class="error-banner">{{ selected.error_message }} · {{ selected.error_code }}</p>
<p v-if="selected.fallback_reason" class="subtle">已回退{{ selected.fallback_reason }}</p>
<p v-for="warning in selected.warnings" :key="warning" class="subtle">{{ ({DIARIZATION_UNAVAILABLE: '当前无法分离说话人', WORD_TIMESTAMPS_UNAVAILABLE: '未提供逐字时间戳', DIARIZATION_SEGMENT_LEVEL: '说话人按音频段估计同段多人或重叠发言需人工校对'} as Record<string,string>)[warning] || warning }}</p>
<button v-if="active(selected)" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.cancel(selected!.job_id) })">取消任务</button>
<button v-if="['failed', 'cancelled'].includes(selected.status) && selected.error_code !== 'MEDIA_PURGED'" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.retry(selected!.job_id) })">重新处理</button>
<button v-if="!active(selected) && selected.error_code !== 'MEDIA_PURGED'" class="button-danger" :disabled="busy" @click="purge">清理原附件与转写</button>
<template v-if="selected.status === 'completed'">
<div class="speaker-names"><label v-for="speaker in speakers" :key="speaker">{{ speaker }}<input v-model="selected.speaker_names[speaker]" class="input" placeholder="说话人显示名" @input="dirty = true" /></label></div>
<p v-if="selected.segments.length" class="subtle">时间戳对应音频分段边界可点击定位播放</p>
<div v-for="segment in selected.segments" :key="segment.segment_id" class="segment" :class="{ current: position >= segment.start_time && position < segment.end_time }">
<button class="button-secondary" @click="seek(segment.start_time)">{{ stamp(segment.start_time) }}</button><small>{{ selected.speaker_names[segment.speaker || ''] || segment.speaker }}</small>
<textarea v-model="segment.text" class="input" rows="2" @input="dirty = true; selected.text = selected.segments.map(s => s.text).join('\n')" />
</div>
<textarea v-if="!selected.segments.length" v-model="selected.text" class="input" rows="12" @input="dirty = true" />
<div class="inline-actions"><button class="button-primary" :disabled="busy || !dirty" @click="action(async () => { selected = await mediaService.save(selected!); dirty = false; notice = '校对已保存' })">保存校对</button>
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">修订历史</button></div>
<details><summary>原始识别文本</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision"><summary>修订 {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<div class="inline-actions"><input v-model="title" class="input" aria-label="笔记标题" /><button class="button-primary" :disabled="busy || dirty || !title.trim()" @click="action(async () => { const note = await mediaService.note(selected!.job_id, title); notice = `已保存笔记:${note.title}` })">保存为笔记</button></div>
</template>
</article>
<div v-else class="panel subtle">选择任务查看转写结果</div>
</div>
</section>
</template>
<style scoped>
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.media-columns{display:grid;grid-template-columns:260px minmax(0,1fr);gap:20px}.panel{padding:20px}.job-row{display:flex;flex-direction:column;gap:6px;width:100%;text-align:left;padding:12px;background:transparent;border:1px solid var(--color-border-default);border-radius:10px;margin-bottom:8px;cursor:pointer;color:inherit}.job-row small{overflow:hidden;text-overflow:ellipsis;max-width:100%}.selected,.current{background:var(--color-background-hover);outline:1px solid var(--color-accent-primary)}.transcript{display:flex;flex-direction:column;gap:16px}.transcript header,.segment{display:flex;gap:12px;align-items:center}.transcript>.button-danger{align-self:flex-start}.transcript>label{white-space:nowrap}.transcript>label select{width:160px}.segment textarea{flex:1}.speaker-names{display:flex;flex-wrap:wrap;gap:10px}audio{width:100%}pre{white-space:pre-wrap;word-break:break-word}label{display:flex;gap:8px;align-items:center}@media(max-width:850px){.media-columns{grid-template-columns:1fr}.segment{flex-wrap:wrap}}
</style>
@@ -0,0 +1,65 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient'
interface Config {device: 'cpu'|'cuda'; cpu_threads: number; memory_limit_mb: number; gpu_memory_limit_mb: number; timeout_seconds: number; embedding_model: string; version: number}
interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; downloaded_bytes: number; total_bytes: number|null; error_code?: string}
const items = ref<Model[]>([])
const config = ref<Config | null>(null)
const installed = ref(false)
const lastInference = ref<{actual_device:string;requested_device:string;inference_seconds:number}|null>(null)
const error = ref('')
const dirty = ref(false)
const busy = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let stopped = false
const size = (bytes: number | null) => bytes === null ? '未知' : `${(bytes / 1024 / 1024).toFixed(1)} MiB`
const labels: Record<string,string> = {not_installed:'未下载',downloading:'下载中',installed:'已下载并校验',failed:'下载失败',interrupted:'已中断,可续传'}
async function load() {
try {
const data = await apiClient.get<{items:Model[];config:Config;runtime_installed:boolean;last_inference:typeof lastInference.value}>('/api/local-models')
items.value = data.items; installed.value = data.runtime_installed
lastInference.value = data.last_inference
if (!dirty.value) config.value = data.config
} catch (e) { error.value = (e as Error).message }
if (!stopped) timer = setTimeout(load, 2000)
}
async function act(work: () => Promise<unknown>) {
error.value = ''; busy.value = true
try { await work() } catch(e) { error.value = (e as Error).message } finally { busy.value = false }
}
async function save() { await act(async () => { config.value = await apiClient.put<Config>('/api/local-models/config', config.value); dirty.value = false }) }
async function diagnostics() {
await act(async () => {
const data = await apiClient.get('/api/local-models/diagnostics')
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], {type:'application/json'}))
const link = document.createElement('a'); link.href = url; link.download = 'local-model-diagnostics.json'; link.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
})
}
onMounted(load)
onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script>
<template>
<section class="local-models">
<h3>本地模型</h3><p class="subtle">默认 CPU下载需要联网推理只读取本地权重文件校验通过不代表当前设备已完成推理验证</p>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<p v-if="lastInference" class="subtle">最近实际运行{{ lastInference.actual_device }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ lastInference.inference_seconds.toFixed(2) }} </p>
<p v-if="!installed" class="subtle">尚未安装模型运行环境在项目根目录执行 <code>./backend/scripts/install-model-runtime.ps1</code>CUDA 选装追加 <code>-Device cuda</code></p>
<form v-if="config" @submit.prevent="save" @input="dirty = true" @change="dirty = true">
<div class="runtime-grid"><label>请求设备<select v-model="config.device" class="select"><option value="cpu">CPU(默认)</option><option value="cuda">CUDA不可用则 CPU</option></select></label>
<label>Embedding<select v-model="config.embedding_model" class="select"><option value="bekko">Bekko A8M</option><option value="granite">Granite 97M 多语言</option></select></label>
<label>CPU 线程<input v-model.number="config.cpu_threads" class="input" type="number" min="1" max="32" /></label>
<label>内存预算 MiB<input v-model.number="config.memory_limit_mb" class="input" type="number" min="1024" max="131072" /></label>
<label>显存预算 MiB<input v-model.number="config.gpu_memory_limit_mb" class="input" type="number" min="512" max="65536" /></label></div>
<p class="subtle">修改 Embedding 后需要重建索引任务按预算串行运行模型在任务结束后释放</p><button class="button-primary" :disabled="busy || !dirty">保存运行设置</button>
</form>
<div class="model-grid"><article v-for="model in items" :key="model.key" class="item-card"><h4>{{ model.name }}</h4><p>{{ model.license }} · {{ labels[model.status] || model.status }}</p><small :title="model.revision">版本 {{ model.revision.slice(0,12) }}</small>
<p>{{ size(model.downloaded_bytes) }} / {{ size(model.total_bytes) }}</p><progress v-if="model.status === 'downloading' && model.total_bytes" :value="model.downloaded_bytes" :max="model.total_bytes" />
<p v-if="model.error_code" class="error-text">{{ model.error_code }}</p><div class="inline-actions">
<button v-if="model.status !== 'installed' && model.status !== 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/download`))">{{ model.status === 'not_installed' ? '下载模型' : '重试 / 续传' }}</button>
<button v-if="model.status === 'downloading'" class="button-secondary" :disabled="busy" @click="act(() => apiClient.post(`/api/local-models/${model.key}/cancel`))">暂停</button>
<button v-if="model.status !== 'not_installed'" class="button-danger" :disabled="busy" @click="act(() => apiClient.delete(`/api/local-models/${model.key}`))">删除权重</button></div>
</article></div><button class="button-secondary" @click="diagnostics">导出本次运行诊断</button><p class="subtle">诊断仅包含模型设备耗时和资源信息不包含正文音频和密钥</p>
</section>
</template>
<style scoped>.local-models{display:grid;gap:16px}.runtime-grid,.model-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}label{display:grid;gap:6px}.item-card{padding:16px}progress{width:100%}</style>
@@ -41,9 +41,9 @@ describe('ModelRoutingSettings', () => {
it('loads local selections honestly, explains index rebuilds, and disables incompatible providers', async () => {
const wrapper = await render()
expect(wrapper.findAll('select').map(select => (select.element as HTMLSelectElement).value)).toEqual(['', '', ''])
expect(wrapper.text()).toContain('当前为占位实现')
expect(wrapper.text()).toContain('真实本地 ASR 尚未接入')
expect(wrapper.text()).toContain('真实本地说话人匹配尚未接入')
expect(wrapper.text()).toContain('本地支持 Bekko / Granite')
expect(wrapper.text()).toContain('本地采用 Qwen3-ASR')
expect(wrapper.text()).toContain('本地采用 ERes2NetV2')
expect(wrapper.text()).toContain('重建全部')
expect(wrapper.text()).toContain('重建完成前继续使用本地检索')
expect(wrapper.text()).toContain('不是 OpenAI 标准接口')
@@ -162,7 +162,7 @@ describe('ModelRoutingSettings', () => {
vi.mocked(service.getModelRouting).mockResolvedValueOnce({ ...initial, local_backends: [{ capability: 'transcription', status: 'ready', message: 'Local ASR ready' }] })
const wrapper = await render()
const card = wrapper.get('[data-capability="transcription"]')
expect(card.get('option[value=""]').text()).toBe('本地 · 已就绪')
expect(card.get('option[value=""]').text()).toBe('本地 · 已安装')
expect(card.text()).toContain('本地后端已就绪')
expect(card.text()).toContain('Local ASR ready')
expect(card.text()).not.toContain('真实本地 ASR 尚未接入')
@@ -6,9 +6,9 @@ import { listProviders } from '@/services/providerService'
import { ApiErrorClass } from '@/services/apiClient'
const capabilities: Array<{ id: RoutingCapability; name: string; endpoint: string; placeholder: string; local: string }> = [
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '当前为占位实现,尚未接入真实本地嵌入模型。' },
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '真实本地 ASR 尚未接入,等待阶段 F;当前无法进行本地语音识别。' },
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '真实本地说话人匹配尚未接入,等待阶段 F;当前无法进行本地声纹匹配。' },
{ id: 'embedding', name: '向量嵌入 · Embedding', endpoint: '/embeddings', placeholder: '例如 text-embedding-3-small', local: '本地支持 Bekko / Granite,安装权重后可离线运行。' },
{ id: 'transcription', name: '语音转写 · Transcription', endpoint: '/audio/transcriptions', placeholder: '输入转写模型 ID', local: '本地采用 Qwen3-ASR 0.6B,默认 CPU。' },
{ id: 'speaker_matching', name: '说话人匹配 · Speaker matching', endpoint: '/audio/speaker-matches', placeholder: '输入说话人匹配模型 ID', local: '本地采用 ERes2NetV2,比对结果是相似度。' },
]
type Draft = { provider_id: string; model: string; endpoint: string; dimensions: string | number }
const drafts = reactive(Object.fromEntries(capabilities.map(item => [item.id, { provider_id: '', model: '', endpoint: item.endpoint, dimensions: '' }])) as Record<RoutingCapability, Draft>)
@@ -26,7 +26,7 @@ const unavailable = computed(() => providers.value.filter(provider => !eligible(
const localBackend = (capability: RoutingCapability) => response.value?.local_backends.find(item => item.capability === capability)
const localLabel = (capability: RoutingCapability) => {
const status = localBackend(capability)?.status
return status === 'ready' ? '已就绪' : status === 'placeholder' ? '占位实现' : '尚未接入'
return status === 'ready' ? '已安装' : status === 'placeholder' ? '测试占位实现' : '未安装'
}
const protocols = [
{ id: 'openai_chat', label: 'OpenAI Chat' }, { id: 'openai_compatible', label: 'OpenAI Compatible' },
@@ -108,7 +108,7 @@ async function save() {
<template>
<section class="routing-settings" aria-labelledby="routing-title" :aria-busy="loading || saving">
<div><h2 id="routing-title">能力模型路由</h2><p class="subtle">向量嵌入语音转写和说话人匹配分别选择提供商与模型独立于默认聊天模型API Key 模型提供商中管理</p></div>
<p class="subtle">未选择提供商即使用本地路径API 请求失败配置不可用或响应无效时服务端会回退到当前本地处理本地占位不代表真实模型已接入</p>
<p class="subtle">未选择提供商即使用本地模型API 请求失败配置不可用或响应无效时回退到本地使用前请下载对应权重并安装运行环境</p>
<p v-if="loading" role="status">正在加载模型路由</p>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions"><button type="button" class="button-secondary" :disabled="loading || saving" @click="load">{{ conflict ? '放弃当前输入并加载最新配置' : response ? '重新加载放弃未保存更改' : '重试加载' }}</button><span v-if="response" class="subtle">配置版本 {{ response.config.version }}</span></div>
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType } from '@/contracts'
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOverride } from '@/contracts'
import * as service from '@/services/providerService'
import ProviderPresetSelector from './ProviderPresetSelector.vue'
import RequestJsonEditor from './RequestJsonEditor.vue'
import { apiClient } from '@/services/apiClient'
const props = defineProps<{ provider?: ProviderConfig; models?: ModelInfo[] }>()
const emit = defineEmits<{ close: []; saved: [provider: ProviderConfig] }>()
@@ -22,6 +24,20 @@ const presetsLoading = ref(false)
const presetsError = ref('')
const saving = ref(false)
const error = ref('')
const requestOverrides = ref<RequestOverride[]>(JSON.parse(JSON.stringify(props.provider?.request_overrides || [])))
const requestJsonValid = ref(true)
const requestPreview = ref('')
async function previewRequest() {
error.value = ''
try {
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
const response = await apiClient.post<{body:Record<string,unknown>}>('/api/providers/request-preview', {
provider: {provider_type:form.provider_type,name:form.name || '预览',base_url:form.base_url || null,
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:true,
})
requestPreview.value = JSON.stringify(response.body, null, 2)
} catch(e) { error.value = (e as Error).message }
}
const contextChanged = ref(false)
const dialog = ref<HTMLElement>()
const previousFocus = document.activeElement as HTMLElement | null
@@ -110,9 +126,10 @@ async function save() {
saving.value = true
try {
if (!form.name.trim() || !form.base_url.trim()) throw new Error('请填写名称和 Base URL。')
if (!requestJsonValid.value) throw new Error('请先修正自定义请求 JSON。')
if (selectedPreset.value?.requires_credential && !apiKey.value.trim() && !configured.value) throw new Error('请输入 API Key。密钥将由后端加密保存。')
// Snapshot before awaiting: closing/unmounting must never create a provider with a changed draft.
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false }
const data = { provider_type: form.provider_type, name: form.name.trim(), base_url: form.base_url.trim() || undefined, default_model: form.default_model.trim(), enabled: form.enabled, capabilities: {}, has_credential: false, request_overrides: requestOverrides.value }
if (apiKey.value.trim()) {
// Rotate even an existing reference: older installations may share preset credential IDs.
const nextId = newCredentialId()
@@ -127,7 +144,7 @@ async function save() {
// A failed status check must not silently unlink the provider's existing credential.
if (credentialError.value && !reference) throw new Error(credentialError.value)
const saved = props.provider
? await service.updateProvider(props.provider.provider_id, { ...data, credential_id: reference ?? null })
? await service.updateProvider(props.provider.provider_id, { ...data, version: props.provider.version, credential_id: reference ?? null })
: await service.createProvider({ ...data, credential_id: reference })
if (active) { emit('saved', saved); close() }
} catch (reason) {
@@ -142,7 +159,7 @@ async function save() {
<div class="form-heading"><h2 id="provider-form-title">{{ provider ? '编辑 Provider' : '新增 Provider' }}</h2><button type="button" class="button-secondary" aria-label="关闭提供商表单" @click="close">关闭</button></div>
<p v-if="presetsLoading" class="subtle" role="status">正在加载提供商预设</p>
<div v-if="presetsError" class="error-banner" role="alert">{{ presetsError }} <button type="button" class="button-secondary" :disabled="presetsLoading || saving" @click="loadPresets">重试</button></div>
<form @submit.prevent="save">
<form @submit.prevent="save" @input="requestPreview = ''" @change="requestPreview = ''">
<fieldset :disabled="saving">
<ProviderPresetSelector :presets="presets" :model-value="form.preset_id" @update:model-value="applyPreset" />
<p v-if="selectedPreset?.description" class="subtle">{{ selectedPreset.description }}</p>
@@ -156,6 +173,9 @@ async function save() {
<label class="field wide"><span>默认聊天模型</span><input v-model="form.default_model" class="input" data-field="model" list="provider-model-options" placeholder="输入模型 ID,或保存后获取模型列表" /><datalist id="provider-model-options"><option v-for="model in modelOptions" :key="model.model_id" :value="model.model_id">{{ model.name }}</option></datalist></label>
</div>
<label class="inline-actions"><input v-model="form.enabled" type="checkbox" /> 启用</label>
<RequestJsonEditor v-model="requestOverrides" @valid="requestJsonValid = $event" />
<button type="button" class="button-secondary" @click="previewRequest">预览最终流式请求隐藏正文</button>
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
</fieldset>
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
<div class="inline-actions form-footer"><button class="button-primary" type="submit" :disabled="saving || credentialLoading">{{ saving ? '保存中…' : '保存提供商' }}</button><button type="button" class="button-secondary" @click="close">取消</button></div>
@@ -172,6 +192,7 @@ fieldset { display: grid; gap: var(--space-md); border: 0; padding: 0; margin: 0
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md); }
.wide { grid-column: 1 / -1; }
.error-text { color: var(--color-error); }
.request-preview { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 300px; overflow: auto; }
.form-footer { padding-top: var(--space-sm); }
@media (max-width: 600px) { .provider-backdrop { padding: 12px; }.provider-modal { padding: var(--space-lg); max-height: 94dvh; }.form-grid { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,20 @@
// @vitest-environment happy-dom
import { mount } from '@vue/test-utils'
import { expect, it } from 'vitest'
import RequestJsonEditor from './RequestJsonEditor.vue'
it('validates object JSON and prevents host-owned fields from being saved', async () => {
const wrapper = mount(RequestJsonEditor, {props: {modelValue: []}})
await wrapper.get('button').trigger('click')
await wrapper.get('textarea').setValue('{"stream":false}')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
expect(wrapper.text()).toContain('运行请求管理字段不可覆盖')
await wrapper.get('textarea').setValue('{"stream_options":{"include_usage":true}}')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([true])
expect(wrapper.emitted('update:modelValue')?.at(-1)?.[0]).toEqual([
{capability:'chat', model:null, stream:null, body:{stream_options:{include_usage:true}}},
])
await wrapper.get('textarea').setValue('[]')
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
wrapper.unmount()
})
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import type { RequestOverride } from '@/contracts'
const props = defineProps<{modelValue: RequestOverride[]}>()
const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>()
const rules = ref(props.modelValue.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''})))
const protectedFields = new Set(['model','messages','input','system','instructions','tools','tool_choice','parallel_tool_calls','functions','function_call','file','audio','reference_file','stream','previous_response_id','conversation','background','store'])
function publish() {
let valid = true
const result: RequestOverride[] = []
for (const rule of rules.value) {
try {
const body = JSON.parse(rule.draft)
if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('顶层必须为 JSON 对象')
const conflicts = Object.keys(body).filter(key => protectedFields.has(key))
if (conflicts.length) throw new Error(`运行请求管理字段不可覆盖:${conflicts.join(', ')}`)
rule.error = ''
result.push({capability:rule.capability,model:rule.model || null,stream:rule.stream ?? null,body})
} catch(e) { rule.error = (e as Error).message; valid = false }
}
emit('valid', valid)
if(valid) emit('update:modelValue', result)
}
function add() { rules.value.push({capability:'chat',model:null,stream:null,body:{},draft:'{}',error:''}); publish() }
function format(index:number) { try { rules.value[index].draft = JSON.stringify(JSON.parse(rules.value[index].draft), null, 2); publish() } catch { publish() } }
watch(() => props.modelValue.length, length => { if (length === 0 && rules.value.length && rules.value.every(r => !r.error)) rules.value = [] })
</script>
<template>
<details class="request-json"><summary>高级自定义请求 JSON</summary>
<p class="subtle">提供商通用规则先应用再应用模型规则对象递归合并数组整体替换null 作为实际值删除键后恢复继承密钥继续使用独立 API Key 配置</p>
<div v-for="(rule,index) in rules" :key="index" class="rule">
<div class="rule-selectors"><label>能力<select v-model="rule.capability" class="select" @change="publish"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">音频转写</option><option value="speaker_matching">声纹比对</option></select></label>
<label>模型<input v-model="rule.model" class="input" placeholder="留空:全部模型" @input="publish" /></label>
<label>请求模式<select v-model="rule.stream" class="select" @change="publish"><option :value="null">全部</option><option :value="true">仅流式</option><option :value="false">仅非流式</option></select></label></div>
<textarea v-model="rule.draft" class="input json-body" rows="6" aria-label="自定义请求 JSON" spellcheck="false" placeholder='{"stream_options":{"include_usage":true}}' @input="publish" />
<p v-if="rule.error" class="error-text" role="alert">{{ rule.error }}</p>
<div class="inline-actions"><button type="button" class="button-secondary" @click="format(index)">格式化</button><button type="button" class="button-danger" @click="rules.splice(index,1); publish()">删除规则</button></div>
</div>
<button type="button" class="button-secondary" @click="add">添加请求规则</button>
</details>
</template>
<style scoped>.request-json{display:grid;gap:12px}.rule{padding:12px;border:1px solid var(--border-color);border-radius:8px;margin:12px 0}.rule-selectors{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}.rule-selectors label{display:grid;gap:5px}.json-body{font-family:monospace;width:100%}</style>
@@ -4,6 +4,8 @@ import type { ProviderConfig } from '@/contracts'
import ProviderForm from './ProviderForm.vue'
import ProviderLogo from './ProviderLogo.vue'
import ModelRoutingSettings from './ModelRoutingSettings.vue'
import LocalModelSettings from './LocalModelSettings.vue'
import UsageCard from './UsageCard.vue'
import { useProviderStore } from '@/stores/provider'
import { useSettingsStore } from '@/stores/settings'
import { useThemeStore } from '@/stores/theme'
@@ -70,6 +72,8 @@ async function chooseDefaultModel(provider: ProviderConfig, event: Event) {
<button class="button-primary" @click="openProvider()">新增 Provider</button>
</div>
<div v-if="providerStore.error || providerAction" class="error-banner">{{ providerStore.error || providerAction }}</div>
<LocalModelSettings />
<UsageCard />
<p v-if="!providerStore.providers.length" class="subtle">{{ providerStore.isLoading ? '正在加载提供商' : '尚无可用提供商请添加真实 API 或本地 Ollama 配置' }}</p>
<div class="provider-list">
<article v-for="provider in providerStore.providers" :key="provider.provider_id" class="item-card provider-card">
@@ -0,0 +1,18 @@
// @vitest-environment happy-dom
import { flushPromises, mount } from '@vue/test-utils'
import { expect, it, vi } from 'vitest'
import { apiClient } from '@/services/apiClient'
import UsageCard from './UsageCard.vue'
vi.mock('@/services/apiClient', () => ({apiClient:{get:vi.fn()}}))
it('shows reported zero separately from missing counters and renders coverage', async () => {
vi.mocked(apiClient.get).mockResolvedValue({totals:{input_tokens:0,output_tokens:12,total_tokens:12,cache_hit_tokens:null,cache_miss_tokens:null,cache_write_tokens:null,reasoning_tokens:null},
coverage:{input_tokens:1,output_tokens:1,total_tokens:1,cache_hit_tokens:0,cache_miss_tokens:0,cache_write_tokens:0,reasoning_tokens:0},
request_count:2,complete_requests:1,cache_hit_rate:null,cache_covered_requests:0,options:[]})
const wrapper = mount(UsageCard)
await flushPromises()
expect(wrapper.findAll('.usage-grid strong').map(node => node.text())).toEqual(['0','12','12','未提供','未提供','未提供','未提供','未提供'])
expect(wrapper.text()).toContain('覆盖 1 / 2 次')
expect(wrapper.text()).toContain('不是厂商账户账单')
wrapper.unmount()
})
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { apiClient } from '@/services/apiClient'
interface Usage {totals: Record<string,number|null>;coverage:Record<string,number>;request_count:number;complete_requests:number;cache_hit_rate:number|null;cache_covered_requests:number;options:{provider_id:string;model:string;source:string}[]}
const data = ref<Usage | null>(null)
const period = ref('7')
const provider = ref('')
const model = ref('')
const source = ref('')
const start = ref('')
const end = ref('')
const busy = ref(false)
const error = ref('')
const metrics: Record<string,string> = {input_tokens:'输入 Token',output_tokens:'输出 Token',total_tokens:'总 Token',cache_hit_tokens:'缓存命中',cache_miss_tokens:'缓存未命中',cache_write_tokens:'缓存写入',reasoning_tokens:'推理 Token'}
async function load() {
busy.value = true; error.value = ''
try {
const until = period.value === 'custom' ? new Date(end.value) : new Date()
const from = period.value === 'custom' ? new Date(start.value) : new Date(until)
if (period.value === 'today') from.setHours(0,0,0,0)
else if (period.value !== 'custom') from.setDate(from.getDate() - Number(period.value))
if (!Number.isFinite(from.getTime()) || !Number.isFinite(until.getTime()) || until <= from) throw new Error('请选择有效的开始与结束时间。')
data.value = await apiClient.get<Usage>('/api/usage', {params: {start:from.toISOString(),end:until.toISOString(),provider_id:provider.value || undefined,model:model.value || undefined,source:source.value || undefined}})
} catch(e) { error.value = (e as Error).message } finally { busy.value = false }
}
onMounted(load)
</script>
<template>
<section class="panel usage-card"><header><h3>Token 消耗情况</h3><button class="button-secondary" :disabled="busy" @click="load">{{ busy ? '加载中' : '刷新统计' }}</button></header>
<div class="filters"><label>时间<select v-model="period" class="select" @change="period !== 'custom' && load()"><option value="today">今日</option><option value="7">近 7 天</option><option value="30">近 30 天</option><option value="custom">自定义</option></select></label>
<label>提供商<select v-model="provider" class="select" @change="model = ''; load()"><option value="">全部</option><option v-for="id in [...new Set(data?.options.map(o => o.provider_id) || [])]" :key="id">{{ id }}</option></select></label>
<label>模型<select v-model="model" class="select" @change="load"><option value="">全部</option><option v-for="id in [...new Set(data?.options.filter(o => !provider || o.provider_id === provider).map(o => o.model) || [])]" :key="id">{{ id }}</option></select></label>
<label>来源<select v-model="source" class="select" @change="load"><option value="">全部</option><option value="api">远程 API</option><option value="local">本地服务</option></select></label>
</div>
<div v-if="period === 'custom'" class="filters"><label>开始<input v-model="start" class="input" type="datetime-local" /></label><label>结束<input v-model="end" class="input" type="datetime-local" /></label><button class="button-secondary" @click="load">应用时间段</button></div>
<p v-if="error" class="error-banner" role="alert">{{ error }}</p>
<template v-if="data"><p v-if="!data.request_count" class="subtle">该时间段没有已记录的模型请求</p>
<div class="usage-grid"><div v-for="(label,key) in metrics" :key="key"><small>{{ label }}</small><strong>{{ data.totals[key] === null ? '未提供' : data.totals[key]?.toLocaleString() }}</strong><small>覆盖 {{ data.coverage[key] }} / {{ data.request_count }} </small></div>
<div><small>缓存命中率</small><strong>{{ data.cache_hit_rate === null ? '未提供' : `${(data.cache_hit_rate * 100).toFixed(1)}%` }}</strong><small>覆盖 {{ data.cache_covered_requests }} </small></div></div>
<p class="subtle">请求 {{ data.request_count }} 其中完整结束 {{ data.complete_requests }} 输入总量包含厂商已报告的缓存推理 Token 不重复加入输出</p>
</template><p class="subtle">统计为本应用观测值不是厂商账户账单缺失指标显示未提供历史未记录的数据不补估</p>
</section>
</template>
<style scoped>.usage-card{display:grid;gap:16px;padding:20px}.usage-card header,.filters{display:flex;gap:12px;align-items:center;flex-wrap:wrap}.usage-card header{justify-content:space-between}.filters label{display:grid;gap:5px}.usage-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:16px}.usage-grid>div{display:grid;gap:8px}.usage-grid strong{font-size:22px}</style>