feat(character): add scenario voice trigger tags#181
Conversation
- 新增 CharacterScenario 数据模型,替代旧的 emotion→action 嵌套结构 - 前端新增 CharacterScenarioSection 组件,位于立绘管理和长期记忆之间 - 每个情景支持:名称、语音上传(预设/参考)、语音文本、AI 翻译标签名 - TTS handler 新增 [tagname] 触发匹配逻辑 - 支持 preset 跳过 TTS / reference 作为参考音色 - 新增场景 CRUD API: /api/characters/scenarios/* - 新增 i18n 键值(zh_CN/en/ja) Co-Authored-By: Claude <noreply@anthropic.com>
- _read_scenarios_from_yaml 对 scenario 的 voice_type 做 strip().lower() - 立绘语音读取兼容 dict 和 Pydantic Sprite 两种类型 Co-Authored-By: Claude <noreply@anthropic.com>
|
@Ame4th review |
Reviewer's Guide by Ameath小爱会沿着关键路径往下看,优先留意容易影响结果的细节。 审查聚焦
文件级变更
Tips and commands
Original review guide in EnglishThe review follows
|
There was a problem hiding this comment.
小爱把需要进一步确认的地方标出来了,咱们逐处看看。
小爱已完成全部变更审查,共发布 10 条行级建议(高风险 0,中风险 8)。
合并前关注点(风险 3 · 建议测试 3)
风险
- PR 元数据未关联 issue;基线
CONTRIBUTING.md要求每个 PR 在说明中链接现有 issue。 - 场景名 AI 翻译将提示构造、LLM adapter 创建和 JSON 解析放在
frontend_bridge_core/characters.py;这偏离基线的薄 bridge 模块边界。 - 变更包含 28 个文件和 1,141 行新增,超过贡献指南“数百行时考虑拆分”的范围提示。
建议测试
- 补充
CharacterSetupPanel通过文件选择器传入File[]的类型与平台上传路径覆盖。 - 补充
TemplateGenerator多角色场景测试,覆盖最后角色有/无标签时r_cname仍列出所选角色。 - 补充
DefaultCharacterTtsHandler从 YAML 加载 preset/reference 标签的覆盖,并验证含translate的台词、连字符标签和句中标签空格。
Prompt for AI Agents
请逐项处理以下审查问题;修改前先核实上下文,修改后运行相关测试。
1. 让角色包往返保存场景 / Preserve scenarios in character package round trips
config/character_config.py:69 (RIGHT)
<issue_to_address>角色包是该配置的持久化消费者:桥接导出会先构造 CharacterConfig,但 tools/file_util.export_character 建立的 char_data 没有 scenarios,导入端也不会重写其 voice_path。导出带标签角色时,整个 speech 目录会被复制,但导入后没有任何场景引用这些音频,标签配置被静默丢失。
Character packages consume this configuration. The exporter omits scenarios from char_data and the importer has no scenario voice-path rebasing, so exporting then importing a tagged character loses its tag configuration while copying orphaned audio.</issue_to_address>
2. 不要用可变索引命名场景音频 / Do not name scenario audio by mutable index
config/character_manager.py:695 (RIGHT)
<issue_to_address>数组索引不是稳定的场景身份。保存两个 .wav 标签后,删除第一个并保存会让原第二个标签保留 scenario_01.wav;再添加一个位于索引 1 的标签并上传 .wav,此处会覆盖同一文件,两个标签随后播放新音频。应使用稳定场景 ID 或不可复用的文件名。
An array index is not stable scenario identity. After deleting an earlier tag, a newly added tag can reuse the old index and overwrite another surviving tag's audio file.</issue_to_address>
3. 不要覆盖角色名约束变量 / Do not overwrite the character-name constraint
llm/template_generator.py:377 (RIGHT)
<issue_to_address>外层 names 原本是已选角色名的字符串;此循环将它重绑定为最后一个角色的情景标签列表。后面的 r_cname 要求仍使用该变量:最后角色没有标签时会写入 [],有标签时会把标签当作允许的角色名,和前面的字段约束形成矛盾并损坏生成对话的协议。
The outer names holds selected character names, but this block rebinds it to scenario tags. The later r_cname requirement therefore receives [] or tag names instead of character names, corrupting the generated-dialog contract.</issue_to_address>
4. 将场景音频与立绘清理生命周期隔离 / Isolate scenario audio from sprite cleanup
config/character_manager.py:692 (RIGHT)
<issue_to_address>现有 delete_all_sprites 会递归删除 VOICE_DIR/<sprite_prefix>,但只清空立绘和情绪标签,不清空 character.scenarios。把新情景音频放入该目录后,“清空全部立绘”会删除 preset/reference 文件却在 YAML 中保留 voice_path,下一次标签触发将使用不存在的音频或参考文件。
delete_all_sprites recursively removes VOICE_DIR/<sprite_prefix> without clearing scenarios. Storing scenario audio there makes Clear all sprites delete configured audio while YAML retains stale paths.</issue_to_address>
5. 不要用陈旧服务器快照覆盖场景草稿 / Do not overwrite scenario drafts with stale server snapshots
frontend/src/features/character-editor/CharacterEditorPage.tsx:405 (RIGHT)
<issue_to_address>名称和语音文本编辑只存在于本地草稿,直到“保存所有标签”。用户随后切换语音类型或上传音频时,局部 API 只基于服务器旧版本更新一个字段,而这里用其返回的完整 scenarios 覆盖草稿,导致未保存的名称/文本回滚;新加但未保存的标签还会因服务器没有该索引而失败。
Name and voice-text edits remain local until Save all. A type change or upload returns a server snapshot based on the old list, and this replacement discards those unsaved edits; a newly added unsaved index fails on the server.</issue_to_address>
6. 首次保存不能丢失场景草稿 / Preserve scenarios on the initial character save
frontend/src/features/character-editor/CharacterEditorPage.tsx:1050 (RIGHT)
<issue_to_address>新建角色时此入口可用,且主保存会提交 draft.scenarios;但 /api/characters 的 _save_character 没有将该字段传给 CharacterManager.add_character,其新建分支也未设置 scenarios。因此“新建角色 → 添加标签 → 保存角色”会显示成功后用空列表回填草稿;browser preview 保留该字段,形成 live/snapshot 不一致。
This control is available while creating a character, but the live /api/characters save drops scenarios when constructing a new character. Creating a character, adding a tag, and saving returns success with the tags lost, unlike browser preview.</issue_to_address>
7. 统一标签语法并保留词间空格 / Align tag grammar and preserve word spacing
core/handlers/tts_message_handler.py:64 (RIGHT)
<issue_to_address>UI 和 schema 未限制标签名只能是 \w+,因此可保存的 sad-face 会被模板列出却永远无法由该正则匹配。并且同一清理逻辑中的 \s* 会同时吞掉标签两侧空白,使 I feel [sad] very tired 变成 I feelvery tired;后续 TTS/UI 清理也重复该行为。应统一标签语法校验与清理规则,并保留词间分隔。
The UI/schema allow names beyond \w+, so a saved sad-face tag never matches. The surrounding \s* also joins words around mid-sentence tags, and the later TTS/UI cleaners repeat it.</issue_to_address>
8. 翻译台词仍需从原文匹配标签 / Match tags from source speech when translation is enabled
core/handlers/tts_message_handler.py:189 (RIGHT)
<issue_to_address>模板规则要求把标签写入 speech,而 translate 是独立的 TTS 目标语言文本。启用 LLM 翻译时,代码在匹配前已用 translate 覆盖 speech_text;合法输出如 speech: "[sad]..."、不含标签的 translate 因而永远不会触发情景语音。应从原始 speech 识别标签,再分别清理用于 TTS 的翻译文本。
The template places tags in speech, but translation replaces speech_text before matching. A valid tagged source line with an untagged translation never activates preset or reference audio.</issue_to_address>
9. 翻译失败时不要显示成功 / Do not report success when translation failed
frontend/src/features/character-editor/CharacterEditorPage.tsx:431 (RIGHT)
<issue_to_address>桥接在 LLM 未配置时返回 { "translated": {}, "error": "LLM not configured" },并且会把 adapter/JSON 失败吞成空翻译;该 mutation 随后只返回本地 updated 数组,所以这里无条件显示成功。点击“AI 翻译”会在没有任何翻译结果时误报成功。
The bridge returns an error payload for an unconfigured LLM and suppresses adapter/JSON failures into an empty result. This mutation discards that status and always shows success, even when no tag was translated.</issue_to_address>
10. 先提交元数据再删除场景音频 / Commit metadata before deleting scenario audio
config/character_manager.py:722 (RIGHT)
<issue_to_address>已注册的 /api/characters/scenarios/voice-delete 会先删除文件、后保存 YAML。若进程在两步之间退出,characters.yaml 仍指向已不存在的 preset/reference 文件;下次触发只能得到无音频或失败的参考路径。现有 delete_sprite_voice 先持久化清空引用,情景删除应采用同样顺序或可恢复的事务。
The registered voice-delete route unlinks the file before persisting YAML. A crash in between leaves a stale preset/reference path; the existing sprite-voice deletion persists the cleared reference first.</issue_to_address>
| voice_dir = os.path.join(VOICE_DIR, character.sprite_prefix) | ||
| Path(voice_dir).mkdir(parents=True, exist_ok=True) | ||
| file_ext = Path(voice_file).suffix | ||
| voice_filename = f"scenario_{scenario_index:02d}{file_ext}" |
There was a problem hiding this comment.
[MEDIUM · bug] 不要用可变索引命名场景音频 / Do not name scenario audio by mutable index
数组索引不是稳定的场景身份。保存两个 .wav 标签后,删除第一个并保存会让原第二个标签保留 scenario_01.wav;再添加一个位于索引 1 的标签并上传 .wav,此处会覆盖同一文件,两个标签随后播放新音频。应使用稳定场景 ID 或不可复用的文件名。
Original in English
An array index is not stable scenario identity. After deleting an earlier tag, a newly added tag can reuse the old index and overwrite another surviving tag's audio file.
置信度 99% · 可直接回复讨论 · 👍/👎 会影响后续审查
| names = [] | ||
| for s in scenarios: | ||
| sn = s.name if hasattr(s, 'name') else s.get('name', '') | ||
| if sn: | ||
| names.append(sn) | ||
| if names: | ||
| voice_tags.append((char_name, names)) | ||
| if voice_tags: | ||
| template += _T("voice_tags_header") | ||
| for char_name, names in voice_tags: | ||
| template += _T("voice_tags_for", name=char_name, tags=", ".join(names)) |
There was a problem hiding this comment.
[MEDIUM · correctness] 不要覆盖角色名约束变量 / Do not overwrite the character-name constraint
外层 names 原本是已选角色名的字符串;此循环将它重绑定为最后一个角色的情景标签列表。后面的 r_cname 要求仍使用该变量:最后角色没有标签时会写入 [],有标签时会把标签当作允许的角色名,和前面的字段约束形成矛盾并损坏生成对话的协议。
Original in English
The outer names holds selected character names, but this block rebinds it to scenario tags. The later r_cname requirement therefore receives [] or tag names instead of character names, corrupting the generated-dialog contract.
置信度 99% · 可直接回复讨论 · 👍/👎 会影响后续审查
| if not voice_file: | ||
| return "请选择语音文件!", None | ||
|
|
||
| voice_dir = os.path.join(VOICE_DIR, character.sprite_prefix) |
There was a problem hiding this comment.
[MEDIUM · bug] 将场景音频与立绘清理生命周期隔离 / Isolate scenario audio from sprite cleanup
现有 delete_all_sprites 会递归删除 VOICE_DIR/<sprite_prefix>,但只清空立绘和情绪标签,不清空 character.scenarios。把新情景音频放入该目录后,“清空全部立绘”会删除 preset/reference 文件却在 YAML 中保留 voice_path,下一次标签触发将使用不存在的音频或参考文件。
delete_all_sprites recursively removes VOICE_DIR/<sprite_prefix> without clearing scenarios. Storing scenario audio there makes Clear all sprites delete configured audio while YAML retains stale paths.
置信度 98% · 可直接回复讨论 · 👍/👎 会影响后续审查
| saveScenarioVoiceType({ name: currentCharacterName, ...input }), | ||
| onSuccess(character) { | ||
| queryClient.invalidateQueries({ queryKey: charactersQueryKey }); | ||
| setDraft((current) => ({ ...current, scenarios: character.scenarios })); |
There was a problem hiding this comment.
[MEDIUM · correctness] 不要用陈旧服务器快照覆盖场景草稿 / Do not overwrite scenario drafts with stale server snapshots
名称和语音文本编辑只存在于本地草稿,直到“保存所有标签”。用户随后切换语音类型或上传音频时,局部 API 只基于服务器旧版本更新一个字段,而这里用其返回的完整 scenarios 覆盖草稿,导致未保存的名称/文本回滚;新加但未保存的标签还会因服务器没有该索引而失败。
Original in English
Name and voice-text edits remain local until Save all. A type change or upload returns a server snapshot based on the old list, and this replacement discards those unsaved edits; a newly added unsaved index fails on the server.
置信度 98% · 可直接回复讨论 · 👍/👎 会影响后续审查
| isSavedCharacter={isSavedCharacter} | ||
| scenarioSavePending={scenarioSaveMutation.isPending} | ||
| translatePending={scenarioTranslateMutation.isPending} | ||
| onAddScenario={scenarioHandlers.addScenario} |
There was a problem hiding this comment.
[MEDIUM · bug] 首次保存不能丢失场景草稿 / Preserve scenarios on the initial character save
新建角色时此入口可用,且主保存会提交 draft.scenarios;但 /api/characters 的 _save_character 没有将该字段传给 CharacterManager.add_character,其新建分支也未设置 scenarios。因此“新建角色 → 添加标签 → 保存角色”会显示成功后用空列表回填草稿;browser preview 保留该字段,形成 live/snapshot 不一致。
Original in English
This control is available while creating a character, but the live /api/characters save drops scenarios when constructing a new character. Creating a character, adding a tag, and saving returns success with the tags lost, unlike browser preview.
置信度 98% · 可直接回复讨论 · 👍/👎 会影响后续审查
| if not speech or not scenarios: | ||
| return None, speech | ||
| tags = re.findall(r'\[(\w+)\]', speech) | ||
| if not tags: |
There was a problem hiding this comment.
[MEDIUM · correctness] 统一标签语法并保留词间空格 / Align tag grammar and preserve word spacing
UI 和 schema 未限制标签名只能是 \w+,因此可保存的 sad-face 会被模板列出却永远无法由该正则匹配。并且同一清理逻辑中的 \s* 会同时吞掉标签两侧空白,使 I feel [sad] very tired 变成 I feelvery tired;后续 TTS/UI 清理也重复该行为。应统一标签语法校验与清理规则,并保留词间分隔。
Original in English
The UI/schema allow names beyond \w+, so a saved sad-face tag never matches. The surrounding \s* also joins words around mid-sentence tags, and the later TTS/UI cleaners repeat it.
置信度 97% · 可直接回复讨论 · 👍/👎 会影响后续审查
| _tag_ref_audio = None | ||
| _tag_prompt = None | ||
| _scenarios = _read_scenarios_from_yaml(name_s) | ||
| _matched, _clean_speech = _match_voice_tag(speech_text, _scenarios) |
There was a problem hiding this comment.
[MEDIUM · correctness] 翻译台词仍需从原文匹配标签 / Match tags from source speech when translation is enabled
模板规则要求把标签写入 speech,而 translate 是独立的 TTS 目标语言文本。启用 LLM 翻译时,代码在匹配前已用 translate 覆盖 speech_text;合法输出如 speech: "[sad]..."、不含标签的 translate 因而永远不会触发情景语音。应从原始 speech 识别标签,再分别清理用于 TTS 的翻译文本。
Original in English
The template places tags in speech, but translation replaces speech_text before matching. A valid tagged source line with an untagged translation never activates preset or reference audio.
置信度 97% · 可直接回复讨论 · 👍/👎 会影响后续审查
| queryClient.invalidateQueries({ queryKey: charactersQueryKey }); | ||
| setDraft((current) => ({ ...current, scenarios: updated })); | ||
| } | ||
| showToast({ kind: "success", title: t("character.voiceTag.aiTranslate") }); |
There was a problem hiding this comment.
[LOW · bug] 翻译失败时不要显示成功 / Do not report success when translation failed
桥接在 LLM 未配置时返回 { "translated": {}, "error": "LLM not configured" },并且会把 adapter/JSON 失败吞成空翻译;该 mutation 随后只返回本地 updated 数组,所以这里无条件显示成功。点击“AI 翻译”会在没有任何翻译结果时误报成功。
Original in English
The bridge returns an error payload for an unconfigured LLM and suppresses adapter/JSON failures into an empty result. This mutation discards that status and always shows success, even when no tag was translated.
置信度 98% · 可直接回复讨论 · 👍/👎 会影响后续审查
| scenario = character.scenarios[scenario_index] | ||
| if scenario.voice_path: | ||
| try: | ||
| os.remove(scenario.voice_path) |
There was a problem hiding this comment.
[LOW · bug] 先提交元数据再删除场景音频 / Commit metadata before deleting scenario audio
已注册的 /api/characters/scenarios/voice-delete 会先删除文件、后保存 YAML。若进程在两步之间退出,characters.yaml 仍指向已不存在的 preset/reference 文件;下次触发只能得到无音频或失败的参考路径。现有 delete_sprite_voice 先持久化清空引用,情景删除应采用同样顺序或可恢复的事务。
Original in English
The registered voice-delete route unlinks the file before persisting YAML. A crash in between leaves a stale preset/reference path; the existing sprite-voice deletion persists the cleared reference first.
置信度 96% · 可直接回复讨论 · 👍/👎 会影响后续审查
- (RachelForster#7) 标签清理正则保留词间空格,避免词汇粘连 - (RachelForster#8) 语音标签匹配移到翻译处理之前,翻译模式不再失效 - (RachelForster#3) template_generator 内循环变量 names→tag_names,不覆盖角色名 - (RachelForster#1) 角色包导出导入保留 scenarios 及 voice_path 重写 - (RachelForster#5) voiceType/voiceText 保存后只合并服务器字段,保留本地未存编辑 - (RachelForster#6) _save_character 创建角色后保存 scenarios - (RachelForster#9) AI翻译检查服务器error字段,无结果时提示而非误报成功 - (RachelForster#2) 场景音频文件名从索引改为场景名,避免删标签后覆盖 - (RachelForster#4) delete_all_sprites 同步清空 scenarios,避免残留失效路径 - (RachelForster#10) delete_scenario_voice 先存YAML再删文件 Co-Authored-By: Claude <noreply@anthropic.com>
功能说明
在角色编辑页「立绘管理」和「长期记忆」之间新增语音触发标签模块。
AI 说话时可以通过
[tagname]触发角色预设的情景语音,例如[sad]匹配到角色 YAML 中配好的 sad 情景,自动切换对应的语音/参考音频。语音优先级
主要改动
测试
🤖 Generated with Claude Code
Co-Authored-By: Claude noreply@anthropic.com
Summary by Ameath
小爱先把这次核对的重点收拢一下,方便继续判断后续处理。
该 PR 在角色编辑、桥接、LLM 模板和 TTS 链路中加入语音触发标签;静态审查发现前端编译、标签执行及跨保存路径持久化方面存在需要修正的问题。
New Features
CharacterScenario配置、角色编辑区和场景语音桥接接口。[tag]选择预设音频或参考音频。Enhancements
voice_type小写归一化及立绘字典/Pydantic 兼容处理。Tests
scenarios断言及平台仓储 mock。Chores
pnpm-workspace.yaml的 esbuild 构建许可配置。Original summary in English
This PR adds voice-trigger tags across the character editor, bridge, LLM template, and TTS pipeline; the static review found issues in frontend compilation, tag execution, and persistence across save paths.
New Features
CharacterScenarioconfiguration, editor UI, and bridge APIs for scenario voices.[tag].Enhancements
voice_typenormalization and dictionary/Pydantic sprite compatibility.Tests
scenariosassertions and platform repository mocks.Chores
pnpm-workspace.yamlesbuild build allowlist.