Files
NotesAgentic/frontend/src/features/media/MediaView.vue
T
2026-09-18 00:56:54 +08:00

240 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import ActionDialog from '@/components/common/ActionDialog.vue'
import { useActionDialog } from '@/composables/useActionDialog'
const { actionDialog, resolveAction, askConfirm } = useActionDialog()
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { apiClient } from '@/services/apiClient'
import { isDesktop } from '@/services/platform/desktop'
import { useRoute } from 'vue-router'
import { mediaService, createMediaSubmission, type MediaJob } from '@/services/mediaService'
import { localeTag, t } from '@/i18n'
import FilePicker from '@/components/common/FilePicker.vue'
import { useProviderStore } from '@/stores/provider'
const route = useRoute()
const providerStore = useProviderStore()
const maxUploadMiB = isDesktop() ? 64 : 128
const submission = createMediaSubmission()
const updateExisting = ref(false)
const jobs = ref<MediaJob[]>([])
const selected = ref<MediaJob | null>(null)
const audioSource = ref('')
watch(() => selected.value?.attachment_id, async (id, _old, onCleanup) => {
let stale = false
let objectUrl: string | undefined
onCleanup(() => { stale = true; if (objectUrl) URL.revokeObjectURL(objectUrl) })
audioSource.value = ''
if (!id) return
if (!isDesktop()) { audioSource.value = mediaService.audio(id); return }
try {
const response = await apiClient.get<Response>(`/api/media/attachments/${encodeURIComponent(id)}`)
const blob = await response.blob()
if (stale) return
objectUrl = URL.createObjectURL(blob)
audioSource.value = objectUrl
} catch (e) { if (!stale) error.value = (e as Error).message }
})
const file = ref<File | null>(null)
const reference = ref<File | null>(null)
const matchResult = ref('')
const terminologyPlaceholder = computed(() => t('{"错误术语": "正确术语"}', '{"incorrect term": "correct term"}'))
const localOnly = ref(false)
const diarization = ref(true)
const terminology = ref('')
const busy = ref(false)
const error = ref('')
const notice = ref('')
const artifactError = ref('')
const artifactNotice = ref('')
const dirty = ref(false)
const title = ref(t('课堂转写', 'Class transcript'))
const knowledgeTitle = ref(t('课堂知识点笔记', 'Class knowledge notes'))
const providerId = ref('')
const model = ref('')
const models = computed(() => providerStore.modelsByProvider[providerId.value] ?? [])
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 = computed(() => ({queued: t('排队中', 'Queued'), running: t('转写中', 'Transcribing'), processing: t('处理中', 'Processing'), completed: t('已完成', 'Completed'), failed: t('失败', 'Failed'), cancelled: t('已取消', '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')}`
const warningLabel = (warning: string) => warning.startsWith('MEDIA_CORRUPT_PACKETS_SKIPPED:')
? t(`已跳过 ${warning.split(':')[1]} 个损坏音频包;缺失时长以静音保留,请校对受影响内容。`, `Skipped ${warning.split(':')[1]} damaged audio packets; missing duration was retained as silence. Review the affected content.`)
: ({
DIARIZATION_UNAVAILABLE: t('当前无法分离说话人', 'Speaker identification is unavailable'),
DIARIZATION_PARTIAL: t('部分片段没有足够语音用于说话人识别,请人工校对', 'Some segments do not contain enough speech for speaker identification; review them manually'),
WORD_TIMESTAMPS_UNAVAILABLE: t('未提供逐字时间戳', 'Word-level timestamps are unavailable'),
DIARIZATION_SEGMENT_LEVEL: t('说话人按音频段估计,同段多人或重叠发言需人工校对', 'Speakers are estimated per segment; multiple or overlapping speakers require manual correction'),
} as Record<string, string>)[warning] || warning
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 && !(await askConfirm(t('当前校对尚未保存,切换后放弃修改?', 'The current corrections are unsaved. Discard them and switch?')))) return
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 }
}
async function submit() {
if (!file.value) return
await action(async () => {
if (file.value!.size > maxUploadMiB * 1024 * 1024) throw new Error(t(`文件不能超过 ${maxUploadMiB} MiB。`, `Files cannot exceed ${maxUploadMiB} MiB.`))
if (file.value!.size > 25 * 1024 * 1024 && !localOnly.value) throw new Error(t('超过 25 MiB 的录音请先启用仅本地处理。', 'Enable local-only processing for audio above 25 MiB.'))
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(t('术语表需要 JSON 对象,值为替换后的文本。', 'The terminology map must be a JSON object whose values are replacement text.'))
}
selected.value = await submission.submit(file.value!, {local_only: localOnly.value,
diarization: diarization.value, terminology: terms})
dirty.value = false
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 } }
async function purge() {
if (!selected.value) return
await action(async () => {
const impact = await mediaService.impact(selected.value!.attachment_id)
if (!(await askConfirm(`${impact.message}\n${t('将保留', 'Will retain')} ${impact.retained_note_ids.length} ${t('篇已保存笔记。确定清理?', 'saved notes. Continue cleanup?')}`))) return
await mediaService.purge(selected.value!.attachment_id)
selected.value = await mediaService.get(selected.value!.job_id)
dirty.value = false; history.value = []; notice.value = t('附件与转写内容已清理', 'Attachment and transcript content were removed')
})
}
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 = `${t('相似度', 'Similarity')} ${result.score.toFixed(3)} · ${result.source === 'local' ? t('本地模型', 'Local model') : 'API'}${result.fallback_reason ? ` · ${t('回退:', 'Fallback: ')}${result.fallback_reason}` : ''}`
} finally {
const cleanup = await Promise.allSettled(temporary.map(id => mediaService.purge(id)))
if (cleanup.some(result => result.status === 'rejected')) notice.value = t('部分临时参考附件清理失败,请检查后端连接。', 'Some temporary reference files could not be removed. Check the backend connection.')
}
})
}
async function createArtifacts() {
if (!selected.value || !providerId.value || !model.value.trim()) return
if (busy.value) return
busy.value = true
artifactError.value = ''
artifactNotice.value = ''
try {
const result = await mediaService.artifacts(selected.value!.job_id, {
title: title.value,
knowledge_title: knowledgeTitle.value,
provider_id: providerId.value,
model: model.value,
update_existing: updateExisting.value,
})
artifactNotice.value = t(
`已生成完整转录稿“${result.transcript.title}”和知识点笔记“${result.knowledge_note.title}”。`,
`Created transcript “${result.transcript.title}” and knowledge notes “${result.knowledge_note.title}”.`,
)
} catch (e) {
artifactError.value = (e as Error).message
} finally {
busy.value = false
}
}
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 Promise.all([refresh(), providerStore.loadProviders()])
providerId.value = providerStore.defaultProviderId
if (typeof route.query.job === 'string') {
try { selected.value = await mediaService.get(route.query.job) } catch (e) { error.value = (e as Error).message }
}
})
watch(providerId, async (value) => {
model.value = providerStore.providers.find(item => item.provider_id === value)?.default_model ?? ''
if (!value) return
try { await providerStore.loadModels(value) } catch { /* 允许手动填写模型 ID。 */ }
})
onUnmounted(() => { stopped = true; clearTimeout(timer) })
</script>
<template>
<section class="media-page">
<ActionDialog v-if="actionDialog" v-bind="actionDialog" @resolve="resolveAction" />
<header class="feature-header"><div><h1>{{ t('音视频转写', 'Media Transcription') }}</h1><p class="subtle">{{ t(`上传音频或视频音轨,转写、校对后保存到知识库。最多 ${maxUploadMiB} MiB;超过 25 MiB 请启用仅本地处理。音轨最长 1 小时。`, `Upload audio or a video soundtrack, transcribe and correct it, then save it to the knowledge base. Up to ${maxUploadMiB} MiB; enable local-only processing above 25 MiB. Audio duration is limited to one hour.`) }}</p></div></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">
<FilePicker :file="file" :label="t('选择附件', 'Choose attachment')" :empty-label="t('尚未选择文件', 'No file selected')" accept=".wav,.mp3,.flac,.ogg,.m4a,.mp4,.webm,.txt,.md" @select="file = $event" />
<div class="upload-options"><label><input v-model="localOnly" type="checkbox" />{{ t('仅本地处理', 'Process locally only') }}</label>
<label><input v-model="diarization" type="checkbox" />{{ t('识别不同说话人', 'Identify different speakers') }}</label></div>
<p class="subtle">{{ localOnly ? t('本次任务不调用远程模型 API,模型需预先下载。', 'This job will not call a remote model API; models must already be downloaded.') : t('若配置了转写 API,将上传所选附件;API 失败后回退到本地模型。', 'When a transcription API is configured, the selected file is uploaded; failures fall back to the local model.') }}</p>
<details class="ui-disclosure"><summary>{{ t('术语校对', 'Terminology corrections') }}</summary><p class="subtle">{{ t('在识别完成后替换文本,原始识别结果会保留。', 'Replace text after recognition while retaining the original result.') }}</p><textarea v-model="terminology" class="textarea" rows="3" :placeholder="terminologyPlaceholder" /></details>
<div class="inline-actions upload-actions"><button type="button" class="button-secondary" :disabled="busy" @click="submission.reset(); notice = t('下一次提交将作为新任务处理', 'The next submission will be processed as a new job')">{{ t('重新处理为新任务', 'Process as new job') }}</button><button class="button-primary" :disabled="busy || !file">{{ busy ? t('处理中…', 'Processing…') : t('上传并转写', 'Upload and transcribe') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('声纹参考比对', 'Speaker reference comparison') }}</summary><p class="subtle">{{ t('将所选附件与参考音频比对。至少各含 1 秒语音;分数是相似度,不是身份认证概率。临时参考文件在比对后清理。', 'Compare the selected file with reference audio. Each must contain at least one second of speech. The score is similarity, not an identity probability. Temporary files are removed afterward.') }}</p>
<FilePicker :file="reference" :label="t('选择参考音频', 'Choose reference audio')" :empty-label="t('尚未选择参考音频', 'No reference audio selected')" accept=".wav,.mp3,.flac,.ogg,.m4a" @select="reference = $event" />
<button type="button" class="button-secondary" :disabled="busy || !file || !reference" @click="compareSpeaker">{{ t('比对声纹', 'Compare speakers') }}</button><p v-if="matchResult">{{ matchResult }}</p></details>
</form>
<div class="media-columns">
<aside class="panel"><h2>{{ t('转写任务', 'Transcription Jobs') }}</h2><p v-if="!jobs.length" class="subtle">{{ t('暂无转写任务', 'No transcription jobs') }}</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(localeTag()) }}</span><small>{{ job.attachment_id }}</small>
</button>
</aside>
<article v-if="selected" class="panel transcript">
<header><h2>{{ labels[selected.status] }}</h2><span class="badge">{{ t('修订', 'Revision') }} {{ selected.revision }}</span></header>
<progress v-if="active(selected) && selected.progress !== null" :value="selected.progress" :max="1" :aria-label="t('转写进度', 'Transcription progress')" />
<audio ref="player" controls :src="audioSource || undefined" @loadedmetadata="loaded" @timeupdate="position = player?.currentTime || 0" />
<label>{{ t('播放速度', 'Playback speed') }}<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">{{ t('已回退', 'Fallback: ') }}{{ selected.fallback_reason }}</p>
<p v-for="warning in selected.warnings" :key="warning" class="subtle">{{ warningLabel(warning) }}</p>
<button v-if="active(selected)" class="button-secondary" :disabled="busy" @click="action(async () => { selected = await mediaService.cancel(selected!.job_id) })">{{ t('取消任务', 'Cancel job') }}</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) })">{{ t('重新处理', 'Process again') }}</button>
<button v-if="!active(selected) && selected.error_code !== 'MEDIA_PURGED'" class="button-danger" :disabled="busy" @click="purge">{{ t('清理原附件与转写', 'Remove attachment and transcript') }}</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="t('说话人显示名', 'Speaker display name')" @input="dirty = true" /></label></div>
<p v-if="selected.segments.length" class="subtle">{{ t('时间戳对应音频分段边界可点击定位播放。', 'Timestamps mark segment boundaries; click one to seek playback.') }}</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="textarea" 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="textarea" 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 = t('校对已保存', 'Corrections saved') })">{{ t('保存校对', 'Save corrections') }}</button>
<button class="button-secondary" @click="action(async () => { history = (await mediaService.revisions(selected!.job_id)).items })">{{ t('修订历史', 'Revision history') }}</button></div>
<details class="ui-disclosure"><summary>{{ t('原始识别文本', 'Original recognition text') }}</summary><pre>{{ selected.original_text }}</pre></details>
<details v-for="revision in history" :key="revision.revision" class="ui-disclosure"><summary>{{ t('修订', 'Revision') }} {{ revision.revision }}</summary><pre>{{ revision.text }}</pre></details>
<section class="artifact-panel">
<div><h3>{{ t('生成课程材料', 'Create course materials') }}</h3><p class="subtle">{{ t('一次生成两份内容:带时间戳的完整转录稿,以及由所选模型提取的知识点笔记。', 'Create two outputs: a timestamped full transcript and knowledge notes extracted by the selected model.') }}</p></div>
<div class="artifact-grid">
<label>{{ t('转录稿标题', 'Transcript title') }}<input v-model="title" class="input" /></label>
<label>{{ t('知识点笔记标题', 'Knowledge-note title') }}<input v-model="knowledgeTitle" class="input" /></label>
<label>{{ t('模型提供商', 'Model provider') }}<select v-model="providerId" class="select"><option value="">{{ t('请选择', 'Select') }}</option><option v-for="item in providerStore.enabledProviders" :key="item.provider_id" :value="item.provider_id">{{ item.name }}</option></select></label>
<label>{{ t('知识提取模型', 'Knowledge extraction model') }}<input v-model="model" class="input" list="media-models" :placeholder="t('填写模型 ID', 'Enter model ID')" /><datalist id="media-models"><option v-for="item in models" :key="item.model_id" :value="item.model_id">{{ item.name }}</option></datalist></label>
</div>
<div class="inline-actions"><label><input v-model="updateExisting" type="checkbox" />{{ t('安全更新上次导出的转录稿', 'Safely update the last exported transcript') }}</label><button class="button-primary" :disabled="busy || dirty || !title.trim() || !knowledgeTitle.trim() || !providerId || !model.trim()" @click="createArtifacts">{{ busy ? t('生成中…', 'Creating…') : t('生成转录稿与知识点笔记', 'Create transcript and knowledge notes') }}</button></div>
<p v-if="artifactError" class="error-banner" role="alert">{{ artifactError }}</p>
<p v-if="artifactNotice" class="artifact-success" role="status">{{ artifactNotice }}</p>
</section>
</template>
</article>
<div v-else class="panel subtle">{{ t('选择任务查看转写结果。', 'Select a job to view its transcript.') }}</div>
</div>
</section>
</template>
<style scoped>
.media-page > :is(.feature-header, .panel, .media-columns, .error-banner) { width: 100%; max-width: 1180px; margin-inline: auto; }
.media-page{padding:28px;overflow:auto;height:100%;display:flex;flex-direction:column;gap:20px}.upload{display:grid;gap:12px;padding:20px}.upload-options{display:flex;flex-wrap:wrap;gap:16px}.upload-actions{justify-content:flex-end}.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}.artifact-panel{display:grid;gap:14px;padding:18px;border:1px solid var(--color-border-default);border-radius:var(--radius-lg);background:var(--color-surface-secondary)}.artifact-panel h3,.artifact-panel p{margin:0}.artifact-success{color:var(--color-success)}.artifact-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.artifact-grid label{align-items:stretch;flex-direction:column;color:var(--color-text-secondary)}.artifact-grid :is(.input,.select){background:var(--color-surface-primary);color:var(--color-text-primary)}audio{width:100%;border-radius:var(--radius-md);accent-color:var(--color-accent-primary)}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}}@media(max-width:620px){.artifact-grid{grid-template-columns:1fr}}@media(max-width:560px){.upload-actions>*{flex:1}.upload-options{flex-direction:column}}
</style>