diff --git a/docs/requirements/handoff-HEL-903.json b/docs/requirements/handoff-HEL-903.json new file mode 100644 index 0000000..a99a4c9 --- /dev/null +++ b/docs/requirements/handoff-HEL-903.json @@ -0,0 +1,57 @@ +{ + "baseline_version": "1.12", + "read_commit": "b09a907747f9836c4aea04016757321d28b7cb6b", + "task": "HEL-903:HEL-902 统一审核后的原作者有限返修(只改 web/ 与本卡交接/证据)——外接服务语音两行按 /api/voice/status 如实区分状态;通话栏显示 session 响应 voice 固定的「本次通话声音」;登记后无明确结果(500/503/响应断连)时提示先刷新列表看编号条目,列表标出缺本地元数据的 cg- 条目。基于 origin/hel-902-integration @ b09a907,保留 HEL-879 真实窗口修复与 HEL-900 作者源码,不推写统一母线、不回退 ce7279e,零真实调用", + "requirement_ids": [ + "CG-006", + "CG-009", + "CG-010", + "CG-013", + "CG-014", + "CG-015", + "CG-016", + "CG-018", + "CG-019", + "CG-020", + "CG-021", + "VIS-003", + "VIS-004", + "VIS-008", + "VIS-010", + "VIS-013", + "UX-001", + "UX-003", + "UX-005", + "UX-006", + "AM-001", + "GOV-001", + "GOV-002", + "GOV-003", + "GOV-004", + "GOV-005", + "GOV-006", + "DEL-001" + ], + "deliverable": "web/src/voice/session.ts:readVoiceStatus 把 GET /api/voice/status 分成 none(无接口)/off(未配置)/ok(已配置且合成健康检查通过)/down(已配置但健康检查未过)/unknown(读不到)五态,voiceStatus 改为由它派生、行为不变;VoiceView 新增 voice(本次通话固定声音,开房前 null,服务器未给或不合规为 {id:null}),只在拿到本代 grant 时写入,结束/失败/迟到旧请求一律随 view 复位;callVoiceOf 只接受列表可容纳的 id。web/src/chat/ChatPanel.tsx:在既有通话条加「本次通话 · 声音名」(default→默认声音、缺字段→声音未说明,名字只按该 id 查列表,查不到显示编号,不用设置里的当前选择),title 写明到本次结束不变、设置里的新选择下一次通话生效。web/src/manage/ManagePanel.tsx:外接服务的语音识别/语音合成两行改为进入分区及点「重新读取状态」时读 /api/voice/status(仅真实入口),按五态如实说明,识别一行写明没有单独健康检查;配置框、按钮、说明卡与文字检查提示随之更正,视觉观察仍写尚未接通。web/src/voice/custom.ts + VoiceSection.tsx:VoiceError 增加 uncertain(已发出后断连、5xx、成功响应体读不全),登记遇到时提示「可能已经登记上了,先刷新列表看 cg- 编号条目,不用再登记」,提供「刷新列表」并把再登记改为「确认没有,再登记」;刷新后对比失败时的自定义列表,指出新出现条目或说明暂无新条目需稍等;列表把无本地元数据的 cg- 条目标「只有编号,缺本地资料」并加说明。4xx 与离线未发出仍为原明确提示。不新增删除入口、不自动删声音。components.css 追加通话条声音标签、状态行配色、编号条目说明与手机按钮换行(只用既有 token,两主题)。测试:tests/hel903_voice_rework.test.ts(11 例)、tests/hel903_mock.mjs、tests/hel903_shots.mjs;tests/hel898_agent_left.test.ts 期望视图补 voice:null。", + "acceptance": [ + "外接服务:语音合成在 ok/down/off/none/unknown(500)/unknown(断连) 下分别显示「已配置 · 合成服务健康检查可连接」「已配置 · 合成服务这会儿暂不可用」「未配置 · 这台服务没有语音配置」「这台服务没有语音状态接口」「状态未知 · 这次没读到语音状态」;语音识别不声称有自己的健康检查;任一状态下语音两行都不出现「尚未接通」或「已接通」;读取状态不申请麦克风;说明写明「可连接」不代表整套语音通话已验收", + "通话:开房后通话条显示 session 响应 voice 对应的「本次通话 · 名称」;通话中另一端改选声音,本次通话显示不变;挂断后标签消失、麦克风释放;下一次通话显示新选择;响应缺 voice 字段显示「声音未说明」而非设置当前选择;default 显示「默认声音」;更早一次通话迟到的 grant 不覆盖新通话(单测)", + "登记:服务器已收下却回 500、或响应中途断开、或 503 时,提示可能已登记并先刷新列表;不自动重读、不自动再登记(上传计数验证);点刷新列表后能指出新出现的 cg- 编号条目并标「只有编号,缺本地资料」;若无新条目则说明稍等再刷、确认没有再登记,确认后再登记一次只发一次;参考声音与已填内容在失败后保留", + "回归:既有上传→登记→试听 canplay→应用流程、换声音只换发声的说明、录音点击开启/停止保留/取消释放麦克风、通话字幕与结束清理均保持;演示入口打开外接服务、她的声音与交谈零 /api 请求" + ], + "deferred": [ + "后台新错误映射(本地元数据落盘失败 OSError→503/422 中文文案等,HEL-902 §5-2)由另卡处理;本卡先兼容既有 500/503,后续统一整合时按合同补核前端文案", + "语音识别没有独立健康状态接口,外接服务只能如实写「没有单独的健康检查」;如需显示识别服务真实状态需后台另行提供", + "编号条目的删除/回收(经 8090 面板自有删除)、自定义音色删除与完整备份恢复(CG-009/R6)继续由后续卡承接,本卡不新增删除入口", + "_capture_voice→worker 读文件的秒级竞态(HEL-902 §5-6)属后台;前端显示的是服务器声明的本次捕获值", + "真实 CosyVoice/FunASR/LiveKit、真机(iPhone/Android/Safari)与 R5 整体验收、R1–R7 完整功能继续承接;HEL-901 仍停放;本卡不部署、不合并" + ], + "requirement_changes": [], + "verification": [ + "web(复用原作者 HEL-900 工作区已存在的同版 node_modules,package-lock SHA256 F950DE58…7A27 一致,经目录联接使用,零下载安装):tsc --noEmit 0 错;eslint src 0 错 1 既有警告(ChatPanel fullButton,非本卡);vitest 148/148(基线 137 + 本卡 11);vite build 通过", + "浏览器:node tests/hel903_mock.mjs 8903 + node tests/hel903_shots.mjs 8903,headless Edge + 假 LiveKit(tests/fake-livekit.js)+ Chromium 假音频设备,90/90 通过:月光/雾光 × 电脑 1440×900/手机 390×844 各 18 项(外接服务、应用声音、本次通话声音固定、下一通更新、字幕、挂断释放、登记 500 后恢复),状态五分支、缺 voice 字段/default、响应断连与 503 恢复、录音释放、演示零请求", + "对照截图:同一 mock 跑 b09a907 原样构建(产物 index-C8UHROe1.js 与 HEL-900 作者构建同名)before 模式三张:外接服务、通话条、登记 500", + "需求检查:python -I scripts/check_requirements.py 与 --handoff docs/requirements/handoff-HEL-903.json 本机执行;本运行环境无 git,--base-ref origin/main 与 --base-ref b09a907 均无法执行,改以与 b09a907 源码包逐文件 SHA256 比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 四组检查交总管在仓库补跑", + "未验证如实声明:假麦克风是浏览器内置提示音;假 LiveKit 不是 WebRTC;试听是 mock 音调不是 TTS;未接真实 8090/9880/FunASR/LiveKit,未部署,未写共享音色,未访问 4080;Safari 与真机未测" + ] +} diff --git a/web/src/chat/ChatPanel.tsx b/web/src/chat/ChatPanel.tsx index 8267924..84659d1 100644 --- a/web/src/chat/ChatPanel.tsx +++ b/web/src/chat/ChatPanel.tsx @@ -11,6 +11,7 @@ import { motionOff } from '../stage/roomScene'; import { LineStage, type PresentHow } from './lineStage'; import { anchorOf, cursorAt, groupTurns, pagesOf, ReadingClock, type Anchor, type Metrics, type ShownMessage } from './reading'; import { mergeTurns, OFF, recentRows, VoiceController, voiceLine, voiceStatus, WORDS, type VoiceView } from '../voice/session'; +import { callVoiceName, listVoices } from '../voice/custom'; import { loadTransport } from '../voice/livekit'; import { newestId, serverId } from '../adapters/inbox'; @@ -89,6 +90,17 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, voiceStatus(c.signal).then(s => setVoiceOff(s.available ? '' : s.reason), () => {}); return () => c.abort(); }, [real]); + // The voice this call was opened with (HEL-903). Its name is looked up by that id only — never the choice settings + // hold now, which is the next call's — and an answer for another id (an older call) is never shown. + const pinned = real ? voiceView.voice : null, [named, setNamed] = useState<{ id: string; label: string } | null>(null); + useEffect(() => { + const id = pinned?.id; if (!id || id === 'default' || named?.id === id) return; + const c = new AbortController(); + listVoices(c.signal).then(list => { const v = !c.signal.aborted && list.configured ? list.voices.find(x => x.id === id) : undefined; if (v) setNamed({ id, label: v.label }); }, () => { /* The id stays on show. */ }); + return () => c.abort(); + // eslint-disable-next-line react-hooks/exhaustive-deps -- one lookup per pinned id. + }, [pinned?.id]); + const pinnedName = pinned && callVoiceName(pinned.id, named); const voiceOn = real ? voiceView.phase !== 'off' : voice; const voiceLabel = { off: '', opening: '正在接通…', joining: `在等${name}接起…`, listening: '在听你说', thinking: `${name}在想`, speaking: `${name}在说 · 开口就能打断` }[voiceView.phase]; useEffect(() => { @@ -334,6 +346,7 @@ export function ChatPanel({ state, onChange, onPresence, onVoice, onObservation, {real && voiceOn &&
{voiceLabel} + {pinned && pinnedName && 本次通话{pinnedName}} {voiceView.spoken ? {name}:{voiceView.spoken}未记入聊天 : voiceView.heard && 「{voiceView.heard}」} {voiceView.blocked && } diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx index 4f7b009..78aee2f 100644 --- a/web/src/manage/ManagePanel.tsx +++ b/web/src/manage/ManagePanel.tsx @@ -5,12 +5,21 @@ import { delay } from '../adapters/demo'; import { exportDemo, parseState } from '../adapters/storage'; import { BaseColor, Button, Field, Icon, Modal, Status } from '../shared/ui'; import { VoiceSection, type VoiceCap } from './VoiceSection'; +import { readVoiceStatus, type VoiceStatusKind } from '../voice/session'; const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']]; // What each section can actually do here (HEL-815 capability tags): read-only, adjustable, demo only, not connected, this device. type Cap = 'ro' | 'edit' | 'demo' | 'off' | 'live' | 'local'; const CAP: Record = { ro: ['只读', ''], edit: ['可以调整', 'accent'], demo: ['演示数据', 'demo'], off: ['未接入', 'off'], live: ['文字已接通', 'ok'], local: ['本机生效', 'ok'] }; const capOf = (id: string, real: boolean, voice: VoiceCap = 'unknown'): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'voice' ? real ? voice === 'off' ? 'off' : 'edit' : 'demo' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo'; const NOTE: Record = { her: '只读资料', relation: '可以调整', voice: '上传 · 录音 · 试听', display: '本机生效' }; +// 外接服务 · voice rows (HEL-903): exactly what GET /api/voice/status can tell — no route, not configured, configured with +// the TTS health probe passing or failing, or unreadable. Recognition has no probe of its own there, so it never claims one. +type VoiceSvc = VoiceStatusKind | 'loading'; +const VOICE_SVC: Record<'asr' | 'tts' | 'config', Record> = { + tts: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 合成服务健康检查可连接', down: '已配置 · 合成服务这会儿暂不可用', unknown: '状态未知 · 这次没读到语音状态' }, + asr: { loading: '正在读取语音状态…', none: '这台服务没有语音状态接口', off: '未配置 · 这台服务没有语音配置', ok: '已配置 · 没有单独的健康检查,以实际通话为准', down: '已配置 · 没有单独的健康检查,以实际通话为准', unknown: '状态未知 · 这次没读到语音状态' }, + config: { loading: '读取中', none: '服务尚未配置', off: '服务尚未配置', ok: '由服务器管理', down: '由服务器管理', unknown: '暂时读不到' }, +}; function CapTag({ cap }: { cap: Cap }) { const [text, tone] = CAP[cap]; return {text}; } // Honest placeholder for a capability that is not connected: what it will do once connected, and that nothing happens now. function Off({ title, will, now }: { title: string; will: string; now: string }) { return

{title}

{will}

{now}

; } @@ -33,6 +42,14 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem const approvedNavigation = useRef(false); const pendingNavigation = useRef<(() => void) | null>(null); const [serviceHealth, setServiceHealth] = useState(); + // Read on opening 外接服务 and on an explicit re-read; real only (the demo never asks a server). + const [voiceSvc, setVoiceSvc] = useState('loading'), [voiceSvcRead, setVoiceSvcRead] = useState(0); + useEffect(() => { + if (!real || !open || section !== 'services') return; + const c = new AbortController(); setVoiceSvc('loading'); + readVoiceStatus(c.signal).then(kind => { if (!c.signal.aborted) setVoiceSvc(kind); }, () => { /* Aborted: left the section. */ }); + return () => c.abort(); + }, [real, open, section, voiceSvcRead]); // 她的声音 (HEL-900): an unregistered reference counts as unsaved; the tag follows what the server reports. const [voiceDirty, setVoiceDirty] = useState(false), [voiceCap, setVoiceCap] = useState('unknown'); const request = useRef(null), generation = useRef(0), feedback = useRef(null); @@ -56,7 +73,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem }, [open, anyDirty]); async function checkService() { setBusy(true); setError(false); setMessage('正在检查文字服务…'); - try { const status = await api('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。语音、视觉和长期记忆仍未接通。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); } + try { const status = await api('health'); setServiceHealth(status); setMessage(status.llm.ok ? '文字服务可达。视觉观察和长期记忆仍未接通;语音状态以上面两行为准。' : '文字模型暂时不可达,档案和历史仍可查看。'); setError(!status.llm.ok); } catch (e) { setMessage((e as Error).message); setError(true); } finally { setBusy(false); } } @@ -187,7 +204,7 @@ export function ManagePanel({ state, open, onClose, onSave, fail, theme, setThem {real && section === 'city' && <> setSearch(e.target.value)}/>
    {['杭州', '上海', '北京', '成都', '广州', '深圳'].filter(c => c.includes(search.trim())).map(c =>
  • )}
{!['杭州', '上海', '北京', '成都', '广州', '深圳'].some(c => c.includes(search.trim())) &&
当前目录没有匹配的城市。
}
} {real && section === 'appearance' && <>
现在的样子
工程外观 v2参考静帧 · 尚无成套日常与交谈视频
    {['清单结构与版本', '媒体格式与时长', '人物一致性', '必要场景覆盖', '可用性与兼容性'].map(label =>
  • 待检查 · {label}
  • )}
当前内置参考静帧
当前参考
导入并通过检查后显示新外观
新旧对比 · 待接入
} {real && section === 'backup' && <>

每日自动备份

暂无可核实的备份记录。自动备份、保留份数与运行结果将在接入后显示。

尚未接通备份服务

完整导出

将包含服务器档案、聊天及届时接通的记录与素材。当前不生成伪备份文件。

    {['检查完整性与版本', '核对是否同一位她', '先备份当前资料', '输入确认并恢复'].map(label =>
  • 待接入 · {label}
  • )}
} - {real && section === 'services' && <>{[['文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['语音识别', '尚未接通'], ['语音合成', '尚未接通'], ['视觉观察', '尚未接通']].map(([label, status], index) =>
{label}{status}
)}

其余能力

{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。

} + {real && section === 'services' && <>{([['text', '文字对话', serviceHealth ? serviceHealth.llm.ok ? '连接可达' : '暂时不可达' : '已接通 · 可重新检查'], ['voice', '语音识别', VOICE_SVC.asr[voiceSvc]], ['voice', '语音合成', VOICE_SVC.tts[voiceSvc]], ['off', '视觉观察', '尚未接通']] as const).map(([kind, label, status]) =>
{label}{status}
{kind === 'text' ? : kind === 'voice' ? : }
)}

语音两项读自服务器的语音状态:「可连接」只说明她的声音(合成)这会儿通过了健康检查,不代表整套语音通话已经验收;语音识别这里查不到单独的状态。这里只读状态,不申请麦克风。

其余能力

{life?.wired ? `生活:${life.label}。${life.note}长期记忆、主动消息和天气尚未接通。` : '生活、长期记忆、主动消息和天气尚未接通。'}文字模型暂时不可达时,已保存的档案和聊天记录仍可查看。

}
{(real ? ['relation', 'display'] : ['relation', 'city', 'services', 'display']).includes(section) &&
{dirty ? '有未保存的修改' : '尚未修改'}
}
{confirm && setConfirm('')} disabled={confirm === 'restore' && confirmText !== '恢复'} onConfirm={() => { const which = confirm; setConfirm(''); if (which === 'leave') { request.current?.abort(); setBusy(false); setDraft({ identity: structuredClone(state.identity), settings: structuredClone(state.settings) }); const action = pendingNavigation.current; pendingNavigation.current = null; approvedNavigation.current = true; try { action?.(); } finally { approvedNavigation.current = false; } } if (which === 'relation') void save(true); if (which === 'appearance') { diff --git a/web/src/manage/VoiceSection.tsx b/web/src/manage/VoiceSection.tsx index 36a88c1..9075a83 100644 --- a/web/src/manage/VoiceSection.tsx +++ b/web/src/manage/VoiceSection.tsx @@ -4,7 +4,7 @@ // (request aborted, audio stopped, object URL revoked), so an old sample can never pass for the new one. import { useEffect, useRef, useState, type RefObject } from 'react'; import { clockOf } from '../adapters/real'; -import { applyVoice, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom'; +import { applyVoice, idOnly, LABEL_MAX, listVoices, PREVIEW_DEFAULT, PREVIEW_MAX, previewVoice, Recorder, registerVoice, SAY, SUGGEST_MAX, SUGGEST_MIN, TEXT_MAX, toReference, VoiceError, type CustomVoice, type RecView, type VoiceSelection } from '../voice/custom'; import { Button, Field, Icon, Status } from '../shared/ui'; export type VoiceCap = 'edit' | 'off' | 'unknown'; @@ -37,6 +37,11 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a // One controller per kind of request; a new one aborts the old. `takeGen` guards decoding a file or a recording. const ctl = useRef>({ list: null, register: null, preview: null, apply: null }); const takeGen = useRef(0), urls = useRef(new Set()), listed = useRef(false); + // HEL-903: a registration that failed without a clear answer may still have been filed. `unsure` holds the custom ids + // listed when it failed, so an explicit refresh can say which entry is new (never a silent second registration). + const [unsure, setUnsure] = useState(false), unsureBefore = useRef | null>(null); + const settleUnsure = () => { unsureBefore.current = null; setUnsure(false); }; + const loadNow = useRef(load); loadNow.current = load; const fresh = (kind: keyof typeof ctl.current) => { ctl.current[kind]?.abort(); const c = new AbortController(); ctl.current[kind] = c; return c; }; const own = (blob: Blob) => { const url = URL.createObjectURL(blob); urls.current.add(url); return url; }; const drop = (url?: string) => { if (url && urls.current.delete(url)) URL.revokeObjectURL(url); }; @@ -69,6 +74,12 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a if (c.signal.aborted) return; if (!list.configured) { setLoad({ state: 'off', text: SAY.notConfigured }); onCap('off'); return; } listed.current = true; setNote(n => n.error ? { text: '', error: false } : n); setLoad({ state: 'ok', voices: list.voices, selection: list.selection }); onCap('edit'); + const before = unsureBefore.current; + if (before) { + const fresh = list.voices.filter(v => v.source === 'custom' && !before.has(v.id)); + if (fresh.length) { settleUnsure(); setRegNote({ text: `列表里多了${fresh.map(v => `「${v.label}」`).join('、')},${SAY.refreshFound}`, error: false }); } + else setRegNote({ text: SAY.refreshNone, error: false }); + } }, e => { if (c.signal.aborted) return; const status = e instanceof VoiceError ? e.status : 0; @@ -86,6 +97,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a try { const ref = await toReference(input); if (gen !== takeGen.current) return; + settleUnsure(); const url = own(ref.wav); setTake(old => { drop(old?.url); return { wav: ref.wav, url, seconds: ref.seconds, from: kind, name: label }; }); setRegNote({ text: '', error: false }); @@ -99,7 +111,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a if (!file || registering) return; void accept(file, 'file', file.name.slice(0, 60)); } - function discardTake() { takeGen.current++; recorder.current?.cancel(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); } + function discardTake() { takeGen.current++; recorder.current?.cancel(); settleUnsure(); setTake(old => { drop(old?.url); return null; }); setRegNote({ text: '', error: false }); } function switchFrom(next: 'file' | 'record') { if (next === from) return; recorder.current?.cancel(); setFrom(next); setRegNote({ text: '', error: false }); } const text = form.text.trim(), needText = !text && !form.allowAsr; @@ -111,6 +123,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a try { const done = await registerVoice(sent.wav, form, c.signal); if (c.signal.aborted) return; + settleUnsure(); setTake(old => { if (old === sent) { drop(old.url); return null; } return old; }); setForm({ label: '', text: '', allowAsr: false }); setRegNote({ text: done.prompt_text_source === 'asr' ? `登记好了:「${done.label}」。服务器转写的参考文字是:${done.prompt_text || '(空)'}` : `登记好了:「${done.label}」。`, error: false }); @@ -119,8 +132,15 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a setReads(n => n + 1); } catch (e) { if (c.signal.aborted) return; - // Nothing is lost on failure: the take, the label and the text all stay for another try. - setRegNote({ text: message(e), error: true }); + // Nothing is lost on failure: the take, the label and the text all stay for another try. When the server may + // have filed it anyway, say so and offer a look at the list first; the list is not re-read behind your back. + if (e instanceof VoiceError && e.uncertain) { + const l = loadNow.current; + if (l.state === 'ok' && !unsureBefore.current) unsureBefore.current = new Set(l.voices.filter(v => v.source === 'custom').map(v => v.id)); + setUnsure(true); + // The server's own words when it gave any; a bare "try again later" would contradict "look first". + setRegNote({ text: e.message === SAY.failed || e.message === SAY.odd ? SAY.maybeRegistered : `${e.message}${SAY.maybeRegistered}`, error: true }); + } else setRegNote({ text: message(e), error: true }); } finally { if (ctl.current.register === c) { ctl.current.register = null; setRegistering(false); } } } @@ -174,7 +194,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a const mine = voices.filter(v => v.source === 'custom'), builtin = voices.filter(v => v.source === 'builtin'); const chosen = voices.find(v => v.id === candidate), ready = sample?.voice === candidate && sample.state === 'ready'; const recording = rec.phase === 'asking' || rec.phase === 'recording'; - const row = (v: CustomVoice) =>
  • ; + const row = (v: CustomVoice) =>
  • ; return
    现在的声音
    {selection.voice ? <>{current?.label || selection.label || selection.voice}{current?.source === 'custom' ? '自定义' : current ? '服务自带' : '这个声音已不在列表里,通话时会用默认声音'}{selection.updated_at ? ` · ${clockOf(selection.updated_at).date} ${clockOf(selection.updated_at).time} 换上` : ''} : <>还没有为她选过通话时用服务默认的声音}
    @@ -197,7 +217,7 @@ export function VoiceSection({ name, active, onDirty, onCap }: { name: string; a setForm(f => ({ ...f, label: e.target.value.replace(/[<>\p{Cc}]/gu, '') }))}/>