diff --git a/docs/rework/hel900/preservation/HEL-900-author.patch b/docs/rework/hel900/preservation/HEL-900-author.patch new file mode 100644 index 0000000..61f6206 --- /dev/null +++ b/docs/rework/hel900/preservation/HEL-900-author.patch @@ -0,0 +1,1188 @@ +diff --git a/docs/requirements/handoff-HEL-900.json b/docs/requirements/handoff-HEL-900.json +new file mode 100644 +--- /dev/null ++++ b/docs/requirements/handoff-HEL-900.json +@@ -0,0 +1,56 @@ ++{ ++ "baseline_version": "1.12", ++ "read_commit": "ce7279e68f64319c080f7ef150cb86c892b81ecb", ++ "task": "HEL-900:在已认可的设置界面接入自定义声音——网页上传或录制参考声音→登记→试听实际合成→应用给同一位她的后续通话;按 docs/voice-custom-contract-HEL899.md §2 接 HEL-899 后台(agent/2/652da8a82e50 @ ce7279e),只改 web/,零真实调用", ++ "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", ++ "UX-003", ++ "UX-006", ++ "AM-001", ++ "GOV-001", ++ "GOV-002", ++ "GOV-003", ++ "GOV-004", ++ "GOV-005", ++ "GOV-006", ++ "DEL-001" ++ ], ++ "deliverable": "设置面板新增「她的声音」分区(沿既有设置结构、组件与两套主题 token,不另起视觉方向):web/src/voice/custom.ts(同源 API 客户端:GET/POST /api/voice/voices、POST /api/voice/preview、POST /api/voice/selection;浏览器解码任意可解音频→16kHz 单声道 16 位 PCM WAV 原始字节体上传,参数走查询串;≥2 秒/≤15MB/静音/解不开在浏览器先拦;allow_asr 仅在参考文字为空且用户明确打开开关时为 true;试听响应须为合法 WAV 才可播放;Recorder 仅由点击开启、只录音频、停止/取消/离开/音轨中断即释放、20 秒自动停、迟到的麦克风授权立即关闭);web/src/manage/VoiceSection.tsx(现在的声音与生效时机、上传/录音二选一、参考原声与「她用这个声音说的·刚刚现场合成」分开标注的主题化播放条、名字/参考文字/转写开关、登记、可用声音列表、试听→浏览器 canplay 确认可播后才能应用、每类请求一代 AbortController + 进行中引用防重复、切换/重试/离开/卸载释放请求与 objectURL、失败保留输入);ManagePanel 接入分区、未登记的参考声音计入未保存离开确认、能力标签随服务器实际状态;components.css 追加分区样式(只用既有 token,两主题与手机断点);离线 mock tests/hel900_mock.mjs、浏览器驱动 tests/hel900_shots.mjs、单元测试 tests/hel900_custom_voice.test.ts", ++ "acceptance": [ ++ "用户可在网页选择本地音频文件或主动点「开始录音」得到参考声音;产物为真实 PCM WAV(mock 端按 RIFF/WAVE 头核验 16kHz/单声道/16 位、时长≥3 秒、Content-Type audio/wav、原始字节体),改扩展名的非 WAV 在浏览器被拒且零上传", ++ "参考文字可填写;留空时须用户明确打开转写开关才传 allow_asr=true,默认不代开;已填写参考文字时一律 allow_asr=false;转写结果回显", ++ "参考原声与实际合成试听分开标注(「你给的声音,不是她的声音」/「刚刚现场合成·不是参考原声」);试听每次重新请求,非 WAV 响应拒绝播放;浏览器 canplay 确认本次新试听可播后「给她用这个声音」才可点;切换声音、重试、离开时中止旧请求、停播并回收 objectURL,迟到的旧试听不出现", ++ "读取并显示当前实际选择;应用成功后显示新声音并说明从下一次通话生效、已接通的通话不中途换声;换声音文案与请求均不触及名字/底色/关系/聊天/记忆;不读写通用助手状态", ++ "失败如实:未配置/无通路/登记服务 503/TTS 503/422/409/断网/拒麦/麦克风占用/非安全来源/录音取消/停止/离开/重复提交/旧异步结果晚到各有中文说明;失败保留参考声音与已填内容;重复点击只发一次登记且不中止已发出的上传", ++ "录音仅由用户点击开启、只请求音频不请求摄像头;停止、取消、切到其他分区、关闭设置、音轨中断后真实音轨全部 ended;打开分区本身只读列表、不申请麦克风;演示入口只显示说明卡、不挂载该组件(代码条件 real && open,未做演示入口浏览器测试)" ++ ], ++ "deferred": [ ++ "真实 CosyVoice 试听、真实 FunASR 转写与克隆相似度、真实麦克风与真机(iPhone/Android/Safari)录音解码:留总工统一审核在获批环境与额度内实测,本卡零真实调用", ++ "后端待补核项(总管已列给统一审核):真实 worker 通话开始读音色的时序、登记服务端成功后本地落盘失败/断连的半状态、ASR/试听实际用量与既有限额的衔接;前端不替后台修实现", ++ "自定义音色删除、完整备份/恢复(CG-009/R6)后续卡;当前界面已写明暂不能在此删除", ++ "交谈页未显示「本次通话所用声音」(POST /api/voice/session 的 voice 字段);如需在通话中提示,可后续小卡补", ++ "设置「外接服务」分区仍沿旧文案显示语音识别/合成「尚未接通」,与本卡新分区及 HEL-878 语音接通不一致,未在本卡改动,交总管决定是否另行修正", ++ "D1 最终音色决定仍归白栖知/总管;R5 整体真机验收与 R1–R7 完整要求继续承接,本卡交付不等于用户已验收音色" ++ ], ++ "requirement_changes": [], ++ "verification": [ ++ "web: tsc --noEmit 0 错;eslint src 0 错(仅既有 ChatPanel.tsx:240 警告,非本卡);vitest 137/137 通过(基线 118 + 本卡 19:WAV 编码/头校验、48k 立体声→16k 单声道、过短/静音/解不开/过大拒绝、API 合同与错误透传、离线零请求、allow_asr 规则、非 WAV 试听拒绝、中止的试听不返回结果、409/503、Recorder 仅点击开启/停止保留/取消丢弃/迟到授权立即关闭/20 秒自动停/音轨中断/拒绝与非安全来源/重复点击单开);vite build 通过", ++ "浏览器:node tests/hel900_mock.mjs 8900 + node tests/hel900_shots.mjs 8900,headless Edge + Chromium 假音频设备,83/83 通过;月光/雾光 × 电脑 1440×900/手机 390×844 主流程截图各 6 张及边界截图(文件上传、断网、登记 503、旧试听丢弃、409、拒麦、离开确认、未配置/无通路/服务不在)", ++ "python -I scripts/check_requirements.py --handoff docs/requirements/handoff-HEL-900.json 通过;本运行环境无 git,--base-ref origin/main 比较未能执行,改以与 ce7279e 源码包逐文件哈希比对确认 docs/requirements 除新增本交接文件外零改动,--base-ref 检查留统一审核在仓库内执行", ++ "未验证如实声明:假麦克风是浏览器内置提示音不是人声;试听是 mock 生成的音调不是 TTS;未接真实 8090/9880/FunASR,未部署,未触碰 HEL-879 现场与共享音色注册表,未访问 4080;Safari 与真机未测" ++ ] ++} +diff --git a/web/src/manage/ManagePanel.tsx b/web/src/manage/ManagePanel.tsx +--- a/web/src/manage/ManagePanel.tsx ++++ b/web/src/manage/ManagePanel.tsx +@@ -4,12 +4,13 @@ + 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'; ++const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['voice', 'mic', '她的声音'], ['city', 'pin', '城市'], ['appearance', 'image', '外观素材'], ['backup', 'box', '本地演示导出'], ['services', 'plug', '外接服务'], ['display', 'moon', '显示']]; +-const sections = [['her', 'user', '她'], ['relation', 'heart', '关系'], ['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: '本机生效' }; +-const capOf = (id: string, real: boolean): Cap => id === 'her' ? 'ro' : id === 'relation' ? 'edit' : id === 'display' ? 'local' : id === 'services' && real ? 'live' : real ? 'off' : 'demo'; +-const NOTE: Record = { her: '只读资料', relation: '可以调整', display: '本机生效' }; + 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}

; } +@@ -32,10 +33,12 @@ + const approvedNavigation = useRef(false); + const pendingNavigation = useRef<(() => void) | null>(null); + const [serviceHealth, setServiceHealth] = useState(); ++ // 她的声音 (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); + useEffect(()=>{if(message) feedback.current?.scrollIntoView({block:'center'});},[message]); + const latestState = useRef(state); latestState.current = state; ++ const dirtySections: Record = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced, voice: real && voiceDirty}; +- const dirtySections: Record = {relation: JSON.stringify(draft.identity)!==JSON.stringify(state.identity), city: !real && draft.settings.city!==state.settings.city, services: !real && JSON.stringify(draft.settings.services)!==JSON.stringify(state.settings.services), display: draft.settings.reduced!==state.settings.reduced}; + const dirty = !!dirtySections[section], anyDirty = Object.values(dirtySections).some(Boolean); + useEffect(() => { if (!open) { + request.current?.abort(); +@@ -168,7 +171,7 @@ +

后来慢慢有的成长 · 单独记录

{p?.growth.length ?
    {p.growth.map(g =>
  • {g.text}{SOURCE[g.source] || '有来处的成长'} · {day(g.ts)}
  • )}
:

{!p ? '读取中…' : '还没有记下的成长。长期记忆尚未接通,聊天记录已独立保存。'}

}
; + } + const sectionLabel = (id: string) => real && id === 'backup' ? '备份' : sections.find(s => s[0] === id)?.[2] || ''; ++ return