feat(multimodal): 完成阶段F运行管理与收尾验收
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { mediaService, type MediaJob } from '@/services/mediaService'
|
||||
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
|
||||
|
||||
const route = useRoute()
|
||||
const submission = createMediaSubmission()
|
||||
const updateExisting = ref(false)
|
||||
const jobs = ref<MediaJob[]>([])
|
||||
const selected = ref<MediaJob | null>(null)
|
||||
const file = ref<File | null>(null)
|
||||
@@ -40,6 +42,7 @@ async function choose(job: MediaJob) {
|
||||
selected.value = JSON.parse(JSON.stringify(job)); dirty.value = false; history.value = []
|
||||
}
|
||||
async function action(work: () => Promise<void>) {
|
||||
if (busy.value) return
|
||||
busy.value = true; error.value = ''; notice.value = ''
|
||||
try { await work() } catch (e) { error.value = (e as Error).message } finally { busy.value = false }
|
||||
}
|
||||
@@ -51,11 +54,10 @@ async function submit() {
|
||||
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})
|
||||
selected.value = await submission.submit(file.value!, {local_only: localOnly.value,
|
||||
diarization: diarization.value, terminology: terms})
|
||||
dirty.value = false
|
||||
jobs.value.unshift(selected.value)
|
||||
jobs.value = [selected.value, ...jobs.value.filter(job => job.job_id !== selected.value?.job_id)]
|
||||
})
|
||||
}
|
||||
function seek(seconds: number) { if (player.value) { player.value.currentTime = seconds; position.value = seconds } }
|
||||
@@ -104,7 +106,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<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>
|
||||
<button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = '下一次提交将作为新任务处理'">重新处理为新任务</button><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>
|
||||
@@ -138,7 +140,7 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<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>
|
||||
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />更新上次导出的笔记(已手动修改则拒绝)</label><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, updateExisting); notice = `已保存笔记:${note.title}` })">保存为笔记</button></div>
|
||||
</template>
|
||||
</article>
|
||||
<div v-else class="panel subtle">选择任务查看转写结果。</div>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
import LocalModelSettings from './LocalModelSettings.vue'
|
||||
|
||||
vi.mock('@/services/apiClient', () => ({apiClient:{get:vi.fn(),post:vi.fn()}}))
|
||||
it('shows an optional CUDA installer and live installation stage', async () => {
|
||||
vi.mocked(apiClient.get).mockImplementation(async (url) => url.includes('runtime-components')
|
||||
? {status:'not_installed', stage:'尚未安装', supported:true, cuda_available:null,custom_interpreter:false}
|
||||
: {items:[],config:null,runtime_installed:true,last_inference:null})
|
||||
vi.mocked(apiClient.post).mockResolvedValue({status:'installing',stage:'下载并安装 PyTorch CUDA(约 3 GB)',supported:true})
|
||||
const wrapper = mount(LocalModelSettings)
|
||||
try {
|
||||
await flushPromises()
|
||||
const button = wrapper.findAll('button').find(b => b.text() === '下载并安装 CUDA 组件')!
|
||||
expect(button.exists()).toBe(true)
|
||||
expect(apiClient.post).not.toHaveBeenCalled()
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/local-models/runtime-components/cuda')
|
||||
expect(wrapper.text()).toContain('下载并安装 PyTorch CUDA')
|
||||
expect(wrapper.get('progress').attributes('value')).toBeUndefined()
|
||||
expect(button.attributes('disabled')).toBeDefined()
|
||||
} finally {wrapper.unmount()}
|
||||
})
|
||||
@@ -2,11 +2,21 @@
|
||||
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}
|
||||
interface Model {key: string; name: string; capability: string; revision: string; license: string; status: string; disk_bytes: number|null; 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)
|
||||
interface CudaComponent {status:string;stage:string;cuda_available:boolean|null;supported:boolean;custom_interpreter:boolean;error?:string;torch?:string}
|
||||
const cuda = ref<CudaComponent|null>(null)
|
||||
const cudaError = ref('')
|
||||
async function loadCuda() {
|
||||
try { cuda.value = await apiClient.get<CudaComponent>('/api/local-models/runtime-components/cuda'); cudaError.value = '' }
|
||||
catch(e) { cudaError.value = (e as Error).message }
|
||||
}
|
||||
async function installCuda() {
|
||||
await act(async () => { cuda.value = await apiClient.post<CudaComponent>('/api/local-models/runtime-components/cuda') })
|
||||
}
|
||||
const lastInference = ref<{actual_device:string;requested_device:string;inference_seconds?:number;elapsed_seconds?:number;status?:string;error_code?:string}|null>(null)
|
||||
const error = ref('')
|
||||
const dirty = ref(false)
|
||||
const busy = ref(false)
|
||||
@@ -15,6 +25,7 @@ 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() {
|
||||
await loadCuda()
|
||||
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
|
||||
@@ -43,8 +54,22 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<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="lastInference" class="subtle">最近实际运行:{{ lastInference.actual_device || '未开始推理' }} · 请求设备 {{ lastInference.requested_device }} · 推理 {{ (lastInference.inference_seconds ?? lastInference.elapsed_seconds ?? 0).toFixed(2) }} 秒 · {{ lastInference.status }} {{ lastInference.error_code || '' }}</p>
|
||||
<p v-if="!installed" class="subtle">尚未安装模型运行环境。在项目根目录执行 <code>./backend/scripts/install-model-runtime.ps1</code>;CUDA 选装追加 <code>-Device cuda</code>。</p>
|
||||
<article class="item-card cuda-components" aria-label="CUDA 运行组件">
|
||||
<h4>CUDA 运行组件(可选)</h4>
|
||||
<p class="subtle">默认使用 CPU。需要 NVIDIA GPU 加速时下载此组件,约 3 GB,安装时还需要额外磁盘空间;不包含显卡驱动和模型权重。</p>
|
||||
<p v-if="cudaError" class="error-text" role="alert">{{ cudaError }} <button class="button-secondary" @click="loadCuda">重新检查</button></p>
|
||||
<template v-if="cuda">
|
||||
<p role="status">{{ cuda.stage }} {{ cuda.torch || '' }}</p>
|
||||
<progress v-if="['checking','installing'].includes(cuda.status)" aria-label="CUDA 组件安装进度" />
|
||||
<p v-if="cuda.error" class="error-text">{{ cuda.error }}</p>
|
||||
<p v-if="!cuda.supported" class="subtle">当前平台暂不支持页面安装,请使用对应平台的模型运行环境。</p>
|
||||
<button v-else-if="cuda.status !== 'installed'" class="button-primary" :disabled="busy || ['checking','installing'].includes(cuda.status)" @click="installCuda">{{ cuda.status === 'installing' ? '正在下载并安装…' : ['failed','interrupted'].includes(cuda.status) ? '重试安装 CUDA 组件' : '下载并安装 CUDA 组件' }}</button>
|
||||
<p v-if="cuda.status === 'installed'" class="subtle">{{ cuda.cuda_available ? '组件已就绪。在下方选择 CUDA 并保存即可启用。' : '组件已安装,但当前未检测到可用 CUDA 设备,将回退 CPU。' }}</p>
|
||||
<p v-if="cuda.custom_interpreter" class="subtle">当前后端设置了 APP_MODEL_PYTHON,优先使用指定环境;要使用页面安装的组件,请移除该覆盖并重启后端。</p>
|
||||
</template>
|
||||
</article>
|
||||
<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>
|
||||
@@ -54,12 +79,12 @@ onUnmounted(() => { stopped = true; clearTimeout(timer) })
|
||||
<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>实际磁盘占用 {{ size(model.disk_bytes) }}</p><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>
|
||||
</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>
|
||||
|
||||
@@ -5,8 +5,11 @@ import type { ProviderConfig, ProviderPreset } from '@/contracts'
|
||||
import * as service from '@/services/providerService'
|
||||
import ProviderForm from './ProviderForm.vue'
|
||||
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
||||
import RequestJsonEditor from './RequestJsonEditor.vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
|
||||
vi.mock('@/services/providerService', () => ({ listProviderPresets: vi.fn(), getCredentialStatus: vi.fn(), putCredential: vi.fn(), createProvider: vi.fn(), updateProvider: vi.fn() }))
|
||||
vi.mock('@/services/apiClient', () => ({ apiClient: { post: vi.fn() } }))
|
||||
const presets: ProviderPreset[] = [
|
||||
{ preset_id: 'deepseek', name: 'DeepSeek', provider_type: 'openai_compatible', base_url: 'https://deepseek.example.test', default_credential_id: 'shared-deepseek', requires_credential: true, logo_id: 'deepseek' },
|
||||
{ preset_id: 'qwen', name: '通义千问', provider_type: 'openai_compatible', base_url: 'https://qwen.example.test', default_credential_id: 'shared-qwen', requires_credential: true, logo_id: 'qwen' },
|
||||
@@ -30,6 +33,21 @@ beforeEach(() => {
|
||||
afterEach(() => { wrappers.splice(0).forEach(wrapper => wrapper.unmount()) })
|
||||
|
||||
describe('ProviderForm', () => {
|
||||
it('invalidates a pending inference result when JSON becomes invalid', async () => {
|
||||
const wrapper = await render(existing)
|
||||
let finish!: (value: {message: string}) => void
|
||||
vi.mocked(apiClient.post).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
const probe = wrapper.findAll('button').find(button => button.text() === '发送测试推理请求')!
|
||||
await probe.trigger('click')
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/api/providers/request-probe', expect.objectContaining({stream:true}))
|
||||
wrapper.getComponent(RequestJsonEditor).vm.$emit('valid', false)
|
||||
await flushPromises()
|
||||
finish({message:'旧配置验证通过'})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).not.toContain('旧配置验证通过')
|
||||
expect(probe.attributes('disabled')).toBeDefined()
|
||||
})
|
||||
|
||||
it('filters compact preset chips and resolves bundled logos', async () => {
|
||||
const wrapper = await render()
|
||||
await wrapper.get('#provider-search').setValue('通义')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, watch, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import type { ModelInfo, ProviderConfig, ProviderPreset, ProviderType, RequestOverride } from '@/contracts'
|
||||
import * as service from '@/services/providerService'
|
||||
import ProviderPresetSelector from './ProviderPresetSelector.vue'
|
||||
@@ -27,16 +27,39 @@ const error = ref('')
|
||||
const requestOverrides = ref<RequestOverride[]>(JSON.parse(JSON.stringify(props.provider?.request_overrides || [])))
|
||||
const requestJsonValid = ref(true)
|
||||
const requestPreview = ref('')
|
||||
const probeResult = ref('')
|
||||
const probing = ref(false)
|
||||
const previewCapability = ref('chat')
|
||||
const previewStream = ref(true)
|
||||
let draftGeneration = 0
|
||||
watch([form, requestOverrides, requestJsonValid, apiKey, previewStream, previewCapability], () => { draftGeneration++; requestPreview.value = ''; probeResult.value = '' }, {deep:true, flush:'sync'})
|
||||
async function previewRequest() {
|
||||
const generation = draftGeneration
|
||||
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,
|
||||
default_model:form.default_model || null,request_overrides:requestOverrides.value}, stream:previewStream.value, capability:previewCapability.value,
|
||||
})
|
||||
requestPreview.value = JSON.stringify(response.body, null, 2)
|
||||
} catch(e) { error.value = (e as Error).message }
|
||||
if (active && generation === draftGeneration) requestPreview.value = JSON.stringify(response.body, null, 2)
|
||||
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
|
||||
}
|
||||
async function probeRequest() {
|
||||
if (probing.value) return
|
||||
error.value = ''; probeResult.value = ''; probing.value = true
|
||||
const generation = draftGeneration
|
||||
try {
|
||||
if (!requestJsonValid.value) throw new Error('请先修正 JSON。')
|
||||
if (apiKey.value.trim()) throw new Error('请先保存新的 API Key,再进行推理验证。')
|
||||
const result = await apiClient.post<{message:string}>('/api/providers/request-probe', {
|
||||
provider: {provider_type:form.provider_type,name:form.name || '推理验证',base_url:form.base_url || null,
|
||||
default_model:form.default_model || null,request_overrides:JSON.parse(JSON.stringify(requestOverrides.value)),
|
||||
credential_id:configured.value ? credentialId.value : null}, stream:previewStream.value,
|
||||
})
|
||||
if (active && generation === draftGeneration) probeResult.value = result.message
|
||||
} catch(e) { if (active && generation === draftGeneration) error.value = (e as Error).message }
|
||||
finally { probing.value = false }
|
||||
}
|
||||
const contextChanged = ref(false)
|
||||
const dialog = ref<HTMLElement>()
|
||||
@@ -174,7 +197,10 @@ async function save() {
|
||||
</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>
|
||||
<div class="inline-actions"><label>预览能力<select v-model="previewCapability" class="select"><option value="chat">聊天</option><option value="embedding">Embedding</option><option value="transcription">转写</option><option value="speaker_matching">声纹</option></select></label><label><input v-model="previewStream" type="checkbox" />流式聊天</label></div>
|
||||
<button type="button" class="button-secondary" @click="previewRequest">预览最终请求(隐藏正文)</button>
|
||||
<button v-if="previewCapability === 'chat'" type="button" class="button-secondary" :disabled="probing || credentialLoading || !requestJsonValid" @click="probeRequest">{{ probing ? '推理验证中…' : '发送测试推理请求' }}</button>
|
||||
<p class="subtle">推理验证会向当前模型发送固定短消息,并计入实际用量。媒体参数请通过真实转写或声纹操作验证。</p><p v-if="probeResult" role="status">{{ probeResult }}</p>
|
||||
<pre v-if="requestPreview" class="request-preview">{{ requestPreview }}</pre>
|
||||
</fieldset>
|
||||
<div v-if="error" class="error-banner" role="alert">{{ error }}</div>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { expect, it } from 'vitest'
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import RequestJsonEditor from './RequestJsonEditor.vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
|
||||
vi.mock('@/services/apiClient', () => ({apiClient:{post:vi.fn()}}))
|
||||
|
||||
it('validates object JSON and prevents host-owned fields from being saved', async () => {
|
||||
const wrapper = mount(RequestJsonEditor, {props: {modelValue: []}})
|
||||
@@ -18,3 +21,32 @@ it('validates object JSON and prevents host-owned fields from being saved', asyn
|
||||
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores an imported configuration that finishes after a newer edit', async () => {
|
||||
let finish!: (value: {request_overrides: unknown[]}) => void
|
||||
vi.mocked(apiClient.post).mockReturnValue(new Promise(resolve => { finish = resolve }))
|
||||
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[]}})
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
const file = new File(['{"version":1,"request_overrides":[]}'], 'rules.json', {type:'application/json'})
|
||||
Object.defineProperty(input.element, 'files', {value:[file], configurable:true})
|
||||
await input.trigger('change')
|
||||
await wrapper.findAll('button').find(button => button.text() === '添加请求规则')!.trigger('click')
|
||||
finish({request_overrides:[{capability:'embedding',body:{dimensions:384}}]})
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
expect(wrapper.findAll('textarea')).toHaveLength(1)
|
||||
expect(wrapper.get('textarea').element.value).toBe('{}')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('restores defaults even from an invalid draft and reflects replacement configurations', async () => {
|
||||
const wrapper = mount(RequestJsonEditor, {props:{modelValue:[{capability:'chat', body:{enable_thinking:false}}]}})
|
||||
await wrapper.get('textarea').setValue('{invalid')
|
||||
expect(wrapper.emitted('valid')?.at(-1)).toEqual([false])
|
||||
await wrapper.findAll('button').find(button => button.text() === '恢复默认请求')!.trigger('click')
|
||||
expect(wrapper.findAll('textarea')).toHaveLength(0)
|
||||
expect(wrapper.emitted('update:modelValue')?.at(-1)).toEqual([[]])
|
||||
await wrapper.setProps({modelValue:[{capability:'embedding', body:{dimensions:384}}]})
|
||||
expect(wrapper.get('textarea').element.value).toContain('384')
|
||||
expect(wrapper.emitted('valid')?.at(-1)).toEqual([true])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { apiClient } from '@/services/apiClient'
|
||||
import type { RequestOverride } from '@/contracts'
|
||||
const props = defineProps<{modelValue: RequestOverride[]}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value:RequestOverride[]]; valid:[value:boolean] }>()
|
||||
const transferError = ref('')
|
||||
let published = JSON.stringify(props.modelValue)
|
||||
let generation = 0
|
||||
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() {
|
||||
generation++
|
||||
let valid = true
|
||||
const result: RequestOverride[] = []
|
||||
for (const rule of rules.value) {
|
||||
@@ -19,11 +24,47 @@ function publish() {
|
||||
} catch(e) { rule.error = (e as Error).message; valid = false }
|
||||
}
|
||||
emit('valid', valid)
|
||||
if(valid) emit('update:modelValue', result)
|
||||
if(valid) { published = JSON.stringify(result); 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 = [] })
|
||||
watch(() => props.modelValue, value => {
|
||||
if (JSON.stringify(value) !== published) {
|
||||
generation++
|
||||
rules.value = value.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
|
||||
published = JSON.stringify(value)
|
||||
emit('valid', true)
|
||||
}
|
||||
}, {deep: true})
|
||||
function reset() { rules.value = []; transferError.value = ''; publish() }
|
||||
async function importRules(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
const current = ++generation
|
||||
transferError.value = ''
|
||||
try {
|
||||
if (file.size > 1024 * 1024) throw new Error('配置文件不得超过 1 MiB')
|
||||
const parsed = JSON.parse(await file.text())
|
||||
const validated = await apiClient.post<{request_overrides: RequestOverride[]}>('/api/providers/request-rules/validate', parsed)
|
||||
if (current !== generation) return
|
||||
rules.value = validated.request_overrides.map(rule => ({...rule, draft: JSON.stringify(rule.body, null, 2), error: ''}))
|
||||
publish()
|
||||
} catch(e) { transferError.value = (e as Error).message }
|
||||
}
|
||||
async function exportRules() {
|
||||
transferError.value = ''
|
||||
try {
|
||||
publish()
|
||||
if (rules.value.some(rule => rule.error)) throw new Error('请先修正 JSON')
|
||||
const validated = await apiClient.post('/api/providers/request-rules/validate', {version:1, request_overrides:JSON.parse(published)})
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(validated, null, 2)], {type:'application/json'}))
|
||||
const link = document.createElement('a'); link.href = url; link.download = 'model-request-rules.json'; link.click()
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
||||
} catch(e) { transferError.value = (e as Error).message }
|
||||
}
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<details class="request-json"><summary>高级:自定义请求 JSON</summary>
|
||||
@@ -37,6 +78,9 @@ watch(() => props.modelValue.length, length => { if (length === 0 && rules.value
|
||||
<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>
|
||||
<div class="inline-actions"><button type="button" class="button-secondary" @click="reset">恢复默认请求</button><button type="button" class="button-secondary" @click="exportRules">导出请求配置</button><label>导入请求配置<input type="file" accept=".json" @change="importRules" /></label></div>
|
||||
<p v-if="transferError" class="error-text" role="alert">{{ transferError }}</p>
|
||||
<p class="subtle">导入替换当前请求规则,保存提供商后生效。导出仅包含请求规则,不包含凭据引用和 API Key。</p>
|
||||
</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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<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}[]}
|
||||
interface Usage {audio_request_count:number;audio_seconds:number|null;audio_covered_requests:number;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('')
|
||||
@@ -37,6 +37,7 @@ onMounted(load)
|
||||
<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.audio_request_count ?? 0 }} 次 · 时长 {{ data.audio_seconds == null ? '未提供' : `${data.audio_seconds.toFixed(2)} 秒` }}(覆盖 {{ data.audio_covered_requests ?? 0 }} 次;重试分别计数)</p>
|
||||
<p class="subtle">请求 {{ data.request_count }} 次,其中完整结束 {{ data.complete_requests }} 次。输入总量包含厂商已报告的缓存,推理 Token 不重复加入输出。</p>
|
||||
</template><p class="subtle">统计为本应用观测值,不是厂商账户账单。缺失指标显示“未提供”,历史未记录的数据不补估。</p>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { createMediaSubmission, mediaService, type MediaJob } from './mediaService'
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
it('reuses upload and job identities after lost responses, until explicitly reset', async () => {
|
||||
const upload = vi.spyOn(mediaService, 'upload').mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValue({attachment_id:'uploaded'})
|
||||
const create = vi.spyOn(mediaService, 'create').mockRejectedValueOnce(new Error('response lost'))
|
||||
.mockResolvedValue({job_id:'same-job'} as MediaJob)
|
||||
const submission = createMediaSubmission()
|
||||
const file = new File(['audio'], 'lecture.wav')
|
||||
const options = {local_only:true}
|
||||
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
|
||||
await expect(submission.submit(file, options)).rejects.toThrow('response lost')
|
||||
expect(await submission.submit(file, options)).toEqual({job_id:'same-job'})
|
||||
expect(upload).toHaveBeenCalledTimes(2)
|
||||
expect(upload.mock.calls[0][1]).toBe(upload.mock.calls[1][1])
|
||||
expect(create.mock.calls[0][0]).toEqual(create.mock.calls[1][0])
|
||||
submission.reset()
|
||||
await submission.submit(file, options)
|
||||
expect(upload.mock.calls[2][1]).not.toBe(upload.mock.calls[1][1])
|
||||
expect(create.mock.calls[2][0]).not.toEqual(create.mock.calls[1][0])
|
||||
})
|
||||
|
||||
it('freezes options across upload and treats changed options as a new request', async () => {
|
||||
let release!: (value:{attachment_id:string}) => void
|
||||
vi.spyOn(mediaService, 'upload').mockImplementationOnce(() => new Promise(resolve => { release = resolve }))
|
||||
.mockResolvedValue({attachment_id:'next'})
|
||||
const create = vi.spyOn(mediaService, 'create').mockResolvedValue({job_id:'job'} as MediaJob)
|
||||
const submission = createMediaSubmission()
|
||||
const file = new File(['audio'], 'lecture.wav')
|
||||
const options = {local_only:true}
|
||||
const pending = submission.submit(file, options)
|
||||
options.local_only = false
|
||||
release({attachment_id:'first'})
|
||||
await pending
|
||||
expect(create.mock.calls[0][0]).toMatchObject({local_only:true})
|
||||
await submission.submit(file, options)
|
||||
expect(create.mock.calls[1][0]).toMatchObject({local_only:false})
|
||||
})
|
||||
@@ -18,15 +18,33 @@ export const mediaService = {
|
||||
revision: job.revision, text: job.text, segments: job.segments, speaker_names: job.speaker_names,
|
||||
}),
|
||||
revisions: (id: string) => apiClient.get<{items: MediaJob[]}>(`/api/media/transcriptions/${encodeURIComponent(id)}/revisions`),
|
||||
note: (id: string, title: string) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title }),
|
||||
note: (id: string, title: string, update_existing = false) => apiClient.post<{note_id: string; title: string}>(`/api/media/transcriptions/${encodeURIComponent(id)}/notes`, { title, update_existing }),
|
||||
audio: (id: string) => resolveApiUrl(`/api/media/attachments/${encodeURIComponent(id)}`),
|
||||
impact: (id: string) => apiClient.get<{message:string;retained_note_ids:string[]}>(`/api/media/attachments/${encodeURIComponent(id)}/cleanup-impact`),
|
||||
purge: (id: string) => apiClient.delete(`/api/media/attachments/${encodeURIComponent(id)}`),
|
||||
async upload(file: File) {
|
||||
async upload(file: File, idempotencyKey?: string) {
|
||||
const response = await fetch(resolveApiUrl(`/api/media/attachments?filename=${encodeURIComponent(file.name)}`), {
|
||||
method: 'POST', headers: {'Content-Type': 'application/octet-stream'}, body: file,
|
||||
method: 'POST', headers: {'Content-Type': 'application/octet-stream', ...(idempotencyKey ? {'Idempotency-Key': idempotencyKey} : {})}, body: file,
|
||||
})
|
||||
if (!response.ok) throw new Error((await response.json())?.error?.message || '附件上传失败')
|
||||
return await response.json() as {attachment_id: string}
|
||||
},
|
||||
}
|
||||
|
||||
// Keep one identity until the input/options change, including a lost HTTP response.
|
||||
// Payloads remain in memory; durable uploads/jobs are owned by the backend.
|
||||
export function createMediaSubmission() {
|
||||
let pending: {file: File; options: string; uploadKey: string; jobKey: string; attachmentId?: string} | null = null
|
||||
return {
|
||||
reset() { pending = null },
|
||||
async submit(file: File, options: Record<string, unknown>) {
|
||||
const serialized = JSON.stringify(options)
|
||||
if (!pending || pending.file !== file || pending.options !== serialized) {
|
||||
pending = {file, options: serialized, uploadKey: crypto.randomUUID(), jobKey: crypto.randomUUID()}
|
||||
}
|
||||
const current = pending
|
||||
if (!current.attachmentId) current.attachmentId = (await mediaService.upload(file, current.uploadKey)).attachment_id
|
||||
return mediaService.create({...JSON.parse(current.options), attachment_id: current.attachmentId, idempotency_key: current.jobKey})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user