From b904844b0977a4946fd278bb69dc6950b09c5271 Mon Sep 17 00:00:00 2001 From: V1per-cmyk Date: Sat, 13 Jun 2026 19:53:44 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=E8=AF=AD=E9=9F=B3=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=A0=87=E7=AD=BE=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- config/character_config.py | 4 +- config/character_manager.py | 113 ++++++++++++ config/schema.py | 12 +- core/handlers/tts_message_handler.py | 50 ++++++ frontend/pnpm-workspace.yaml | 2 + frontend/src/entities/character/repository.ts | 82 ++++++--- frontend/src/entities/config/types.ts | 1 + .../character-editor/CharacterEditorPage.css | 117 ++++++++++++ .../character-editor/CharacterEditorPage.tsx | 156 +++++++++++++++- .../CharacterScenarioSection.tsx | 166 ++++++++++++++++++ .../character-editor/characterEditorUtils.ts | 1 + frontend/src/shared/i18n/messages.ts | 37 +++- frontend/src/shared/i18n/messages/en.ts | 19 ++ frontend/src/shared/i18n/messages/ja.ts | 19 ++ frontend/src/shared/i18n/messages/zh_CN.ts | 19 ++ .../shared/platform/browserPreviewPlatform.ts | 41 +++++ frontend/src/shared/platform/httpPlatform.ts | 26 +++ frontend/src/shared/platform/types.ts | 20 +++ .../src/test/characterEditorUtils.test.ts | 1 + .../characterEditorUtils.test.ts | 1 + frontend/src/test/repositories.test.ts | 6 + frontend_bridge_core/characters.py | 127 ++++++++++++++ frontend_bridge_core/handler.py | 21 +++ 23 files changed, 1012 insertions(+), 29 deletions(-) create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/src/features/character-editor/CharacterScenarioSection.tsx diff --git a/config/character_config.py b/config/character_config.py index 4f7b2f7b..2c2ffefc 100644 --- a/config/character_config.py +++ b/config/character_config.py @@ -1,7 +1,7 @@ import yaml class CharacterConfig: - def __init__(self, name, color, sprite_prefix, gpt_model_path=None, sovits_model_path=None, refer_audio_path=None, prompt_text=None, prompt_lang=None, sprites=[], emotion_tags="", sprite_scale=1.0, character_setting="", speech_speed=1.0, speech_volume=1.0, pronunciation_map=None): + def __init__(self, name, color, sprite_prefix, gpt_model_path=None, sovits_model_path=None, refer_audio_path=None, prompt_text=None, prompt_lang=None, sprites=[], emotion_tags="", sprite_scale=1.0, character_setting="", speech_speed=1.0, speech_volume=1.0, pronunciation_map=None, scenarios=None): # 角色基本信息 self.name = name self.color = color @@ -10,6 +10,7 @@ def __init__(self, name, color, sprite_prefix, gpt_model_path=None, sovits_model self.character_setting = character_setting self.sprite_scale = sprite_scale self.emotion_tags = emotion_tags + self.scenarios = scenarios or [] self.speech_speed = speech_speed self.speech_volume = speech_volume self.pronunciation_map = pronunciation_map or {} @@ -65,6 +66,7 @@ def parse_dic(char_data): speech_speed=char_data.get("speech_speed", 1.0), speech_volume=char_data.get("speech_volume", 1.0), pronunciation_map=char_data.get("pronunciation_map", None), + scenarios=char_data.get("scenarios", []), ) return character \ No newline at end of file diff --git a/config/character_manager.py b/config/character_manager.py index b4fcc5b1..866d86f7 100644 --- a/config/character_manager.py +++ b/config/character_manager.py @@ -646,6 +646,119 @@ def save_sprite_scale(self, name: str, scale: float) -> str: return f"找不到角色: {name}" + # ---------- 情景模块 ---------- + + + def get_character_scenarios(self, character_name: str) -> List[dict]: + """获取角色的情景配置列表""" + character = self._config_manager.get_character_by_name(character_name) + if not character: + return [] + if hasattr(character, 'model_dump'): + return character.model_dump().get('scenarios', []) + return getattr(character, 'scenarios', []) or [] + + + def save_character_scenarios(self, character_name: str, scenarios: List[dict]) -> str: + """保存角色的完整情景配置(替换模式)""" + if not character_name: + return "请先选择角色!" + character = self._config_manager.get_character_by_name(character_name) + if not character: + return f"找不到角色: {character_name}" + from config.schema import CharacterScenario + validated = [CharacterScenario.model_validate(s) for s in scenarios] + character.scenarios = validated + self._config_manager.save_characters_config() + return "情景配置已保存" + + + def upload_scenario_voice( + self, character_name: str, scenario_index: int, + voice_file: str, voice_text: str, voice_type: str = "", + ) -> Tuple[str, Optional[str]]: + """为指定情景上传语音文件""" + if not character_name: + return "请先选择角色!", None + character = self._config_manager.get_character_by_name(character_name) + if not character: + return f"找不到角色: {character_name}", None + if not character.scenarios or scenario_index < 0 or scenario_index >= len(character.scenarios): + return "情景不存在!", None + scenario = character.scenarios[scenario_index] + if not voice_file: + return "请选择语音文件!", None + + 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}" + voice_path = os.path.join(voice_dir, voice_filename) + shutil.copyfile(voice_file, voice_path) + + scenario.voice_path = voice_path + scenario.voice_text = voice_text + if voice_type: + scenario.voice_type = voice_type + + self._config_manager.save_characters_config() + return f"语音已上传到情景 {scenario_index+1}", voice_path + + + def delete_scenario_voice( + self, character_name: str, scenario_index: int, + ) -> str: + """删除指定情景的语音文件和引用""" + if not character_name: + return "请先选择角色!" + character = self._config_manager.get_character_by_name(character_name) + if not character: + return f"找不到角色: {character_name}" + if not character.scenarios or scenario_index < 0 or scenario_index >= len(character.scenarios): + return "情景不存在!" + scenario = character.scenarios[scenario_index] + if scenario.voice_path: + try: + os.remove(scenario.voice_path) + except OSError: + pass + scenario.voice_path = None + scenario.voice_text = None + scenario.voice_type = None + self._config_manager.save_characters_config() + return "语音已删除" + + + def save_scenario_voice_text( + self, character_name: str, scenario_index: int, voice_text: str, + ) -> str: + """单独保存情景的语音文本""" + if not character_name: + return "请先选择角色!" + character = self._config_manager.get_character_by_name(character_name) + if not character: + return f"找不到角色: {character_name}" + scenario = character.scenarios[scenario_index] + scenario.voice_text = voice_text + self._config_manager.save_characters_config() + return "语音文本已保存" + + + def save_scenario_voice_type( + self, character_name: str, scenario_index: int, voice_type: str, + ) -> str: + """单独保存情景的语音类型(preset/reference)""" + if not character_name: + return "请先选择角色!" + character = self._config_manager.get_character_by_name(character_name) + if not character: + return f"找不到角色: {character_name}" + scenario = character.scenarios[scenario_index] + scenario.voice_type = voice_type if voice_type else None + self._config_manager.save_characters_config() + return "语音类型已保存" + + def load_characters_from_file(self) -> Tuple[str, List[List[str]]]: """ 重新加载人物设定文件。 diff --git a/config/schema.py b/config/schema.py index 4dffd9ce..49499bd5 100644 --- a/config/schema.py +++ b/config/schema.py @@ -39,18 +39,28 @@ class Sprite(BaseModel): voice_path: Optional[FilePath] = Field(None, description="对应的语音文件的路径 (可选)") voice_text: Optional[str] = Field(None, description="语音对应的文本内容 (可选, 存在于某些条目中)") + +class CharacterScenario(BaseModel): + """角色的单个情景(情绪或动作),由 AI 自主识别""" + name: str = Field(..., description="情景名称(英文,如 happy / kiss / angry)") + voice_path: Optional[str] = Field(None, description="语音文件路径(可选)") + voice_type: Optional[str] = Field(None, description="语音类型: preset 或 reference") + voice_text: Optional[str] = Field(None, description="语音对应文本内容") + + class Character(BaseModel): """单个角色配置的实体模型""" # 角色基本信息 name: str = Field(..., description="角色名称") color: str = Field(..., description="角色对话框或名字的颜色") sprite_prefix: str = Field(..., description="立绘文件名的通用前缀") - + # 列表中可能包含 Sprite 模型,也可能只是原始字典 sprites: List[Union[Sprite, dict]] = Field(default_factory=list, description="角色的立绘和对应语音的列表") character_setting: DefaultIfNone[str] = Field(default="", description="角色背景、性格和语言习惯的详细描述") sprite_scale: DefaultIfNone[float] = Field(default=1.0, description="立绘的缩放比例 (默认值 1.0)") emotion_tags: DefaultIfNone[str] = Field(default="", description="情绪标签和对应的立绘编号描述") + scenarios: List[CharacterScenario] = Field(default_factory=list, description="情景配置列表(由 AI 自主识别情绪或动作)") # gpt-sovits 相关的配置 gpt_model_path: Optional[str] = Field('', description="角色 GPT 模型的路径 (可选)") diff --git a/core/handlers/tts_message_handler.py b/core/handlers/tts_message_handler.py index e2907050..54ee0d81 100644 --- a/core/handlers/tts_message_handler.py +++ b/core/handlers/tts_message_handler.py @@ -23,6 +23,7 @@ from sdk.messages import LLMDialogMessage, TTSOutputMessage from core.runtime.app_runtime import get_app_runtime, tts_emit_to_ui_queue from i18n import tr as tr_i18n +import re _config = ConfigManager() @@ -31,6 +32,25 @@ def get_character_by_name(name: str): return _config.get_character_by_name(name) +def _match_voice_tag(speech: str, scenarios: list): + """检测台词中的 [tagname] 标签,匹配角色情景配置。 + 返回匹配到的 scenario dict,未匹配返回 None。 + 同时返回去除标签后的纯净台词。 + """ + if not speech or not scenarios: + return None, speech + tags = re.findall(r'\[(\w+)\]', speech) + if not tags: + return None, speech + for tag in tags: + for s in scenarios: + s_name = s.name if hasattr(s, 'name') else s.get('name', '') + if s_name.lower() == tag.lower(): + clean = re.sub(r'\s*\[\w+\]\s*', '', speech).strip() + return s, clean + return None, speech + + def _post_tts_busy(text: str) -> None: try: get_app_runtime().ui_update_manager.post_busy_bar(text, 0.0) @@ -139,6 +159,31 @@ def handle(self, msg: LLMDialogMessage) -> None: text_processor = None speech_text = rt.text_processor.remove_parentheses(translate) speech_text = rt.text_processor.replace_names(speech_text) + + # 语音触发标签:[tagname] 匹配角色情景,触发对应语音 + _scenarios = getattr(character_config, 'scenarios', None) or [] + _matched, _clean_speech = _match_voice_tag(speech_text, _scenarios) + if _matched: + _m_vt = _matched.voice_type if hasattr(_matched, 'voice_type') else _matched.get('voice_type') + _m_vp = _matched.voice_path if hasattr(_matched, 'voice_path') else _matched.get('voice_path') + _m_vtext = _matched.voice_text if hasattr(_matched, 'voice_text') else _matched.get('voice_text') + if _m_vt == "preset" and _m_vp: + audio_path = Path(_m_vp).resolve().as_posix() + _hide_tts_busy() + tts_emit_to_ui_queue( + name_s, _m_vtext or _clean_speech, str(asset_id), audio_path, + is_system_message=False, effect=msg.effect, + ) + return + elif _m_vt == "reference" and _m_vp: + # 标签触发参考音色:用标签配置作为 TTS 参考 + speech_text = _clean_speech + _tag_ref_audio = _m_vp + _tag_prompt = _m_vtext or "" + else: + _tag_ref_audio = None + _tag_prompt = None + audio_path = "" if rt.tts_manager: _post_tts_busy(tr_i18n("desktop.tts_busy_synthesizing", name=name_s)) @@ -166,6 +211,11 @@ def handle(self, msg: LLMDialogMessage) -> None: prompt_text = sprite_data.get("voice_text") except Exception: print("没有立绘") + # 语音触发标签匹配到 reference 时,用标签配置覆盖参考音频 + if _tag_ref_audio: + ref_audio_path = Path(_tag_ref_audio).resolve().as_posix() + if _tag_prompt: + prompt_text = _tag_prompt if text_processor: speech_text = text_processor.remove_parentheses(speech_text) diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 00000000..5ed0b5af --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/frontend/src/entities/character/repository.ts b/frontend/src/entities/character/repository.ts index 8fdb50c9..6a4ef1a2 100644 --- a/frontend/src/entities/character/repository.ts +++ b/frontend/src/entities/character/repository.ts @@ -1,10 +1,10 @@ +import type { Character, CharacterScenario } from "../../entities/config/types"; import { getPlatform } from "../../shared/platform/platform"; -import type { Character } from "../config/types"; - -export const charactersQueryKey = ["characters"] as const; export function listCharacters() { - return getPlatform().characters.list(); + return getPlatform() + .config.get() + .then((config) => config.characters); } export function saveCharacter(character: Character, originalName?: string) { @@ -15,8 +15,8 @@ export function deleteCharacter(name: string) { return getPlatform().characters.delete(name); } -export function importCharacters(items: File[] | string[]) { - return getPlatform().characters.import(items); +export function importCharacters(paths: string[]) { + return getPlatform().characters.import(paths); } export function exportCharacter(name: string) { @@ -27,38 +27,26 @@ export function generateCharacterSetting(input: { name: string; setting: string return getPlatform().characters.generateSetting(input); } -export function translateCharacterFields(input: { characterSetting: string; emotionTags: string; name: string }) { +export function translateCharacterFields(input: { name: string; characterSetting: string; emotionTags: string }) { return getPlatform().characters.translateFields(input); } -export function listCharacterMemories(name: string) { - return getPlatform().characters.listMemories(name); -} - -export function rememberCharacterMemory(name: string, content: string) { - return getPlatform().characters.remember(name, content); +export function uploadCharacterSprites(input: { name: string; paths: string[]; emotionTags: string }) { + return getPlatform().characters.uploadSprites(input); } -export function deleteCharacterMemory(name: string, memoryId: string) { - return getPlatform().characters.deleteMemory(name, memoryId); +export function deleteCharacterSprite(name: string, index: number) { + return getPlatform().characters.deleteSprite(name, index); } -export function uploadCharacterSprites(input: { emotionTags: string; name: string; paths: string[] }) { - return getPlatform().characters.uploadSprites(input); +export function deleteAllCharacterSprites(name: string) { + return getPlatform().characters.deleteAllSprites(name); } export function saveCharacterEmotionTags(name: string, emotionTags: string) { return getPlatform().characters.saveEmotionTags(name, emotionTags); } -export function deleteCharacterSprite(name: string, spriteIndex: number) { - return getPlatform().characters.deleteSprite(name, spriteIndex); -} - -export function deleteAllCharacterSprites(name: string) { - return getPlatform().characters.deleteAllSprites(name); -} - export function saveSpriteScale(name: string, scale: number) { return getPlatform().characters.saveSpriteScale(name, scale); } @@ -74,3 +62,47 @@ export function saveSpriteVoiceText(name: string, spriteIndex: number, voiceText export function deleteSpriteVoice(name: string, spriteIndex: number) { return getPlatform().characters.deleteSpriteVoice(name, spriteIndex); } + +// ---------- 情景模块 ---------- + +export function saveCharacterScenarios(name: string, scenarios: CharacterScenario[]) { + return getPlatform().characters.saveScenarios(name, scenarios); +} + +export function uploadScenarioVoice(input: { + name: string; + scenarioIndex: number; + voicePath: string; + voiceText: string; + voiceType: string; +}) { + return getPlatform().characters.uploadScenarioVoice(input); +} + +export function deleteScenarioVoice(name: string, scenarioIndex: number) { + return getPlatform().characters.deleteScenarioVoice(name, scenarioIndex); +} + +export function saveScenarioVoiceText(input: { name: string; scenarioIndex: number; voiceText: string }) { + return getPlatform().characters.saveScenarioVoiceText(input); +} + +export function saveScenarioVoiceType(input: { name: string; scenarioIndex: number; voiceType: string }) { + return getPlatform().characters.saveScenarioVoiceType(input); +} + +// ---------- memory ---------- + +export function listCharacterMemories(name: string) { + return getPlatform().characters.listMemories(name); +} + +export function rememberCharacterMemory(name: string, content: string) { + return getPlatform().characters.remember(name, content); +} + +export function deleteCharacterMemory(name: string, memoryId: string) { + return getPlatform().characters.deleteMemory(name, memoryId); +} + +export const charactersQueryKey = ["characters"] as const; diff --git a/frontend/src/entities/config/types.ts b/frontend/src/entities/config/types.ts index 289e5c15..a5707946 100644 --- a/frontend/src/entities/config/types.ts +++ b/frontend/src/entities/config/types.ts @@ -6,6 +6,7 @@ export type { AppConfig, Background, Character, + CharacterScenario, Sprite, SystemConfig, } from "../../shared/platform/types"; diff --git a/frontend/src/features/character-editor/CharacterEditorPage.css b/frontend/src/features/character-editor/CharacterEditorPage.css index c6ef1764..38ca8333 100644 --- a/frontend/src/features/character-editor/CharacterEditorPage.css +++ b/frontend/src/features/character-editor/CharacterEditorPage.css @@ -247,3 +247,120 @@ position: static; } } + +/* ── Voice trigger tags ── */ + +.scenario-list { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.scenario-card { + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + padding: var(--space-2) var(--space-3); + position: relative; +} + +.scenario-card__actions { + position: absolute; + top: var(--space-1); + right: var(--space-2); +} + +.scenario-card__row { + display: flex; + align-items: center; + gap: var(--space-2); + flex-wrap: wrap; +} + +.scenario-card__index { + font-size: var(--font-sm); + color: var(--color-text-muted); + white-space: nowrap; + min-width: 40px; +} + +.scenario-card__name { + width: 110px; + flex: 0 0 auto; +} + +.scenario-card__filename { + font-size: 11px; + color: var(--color-text-muted); + max-width: 130px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scenario-card__player { + flex: 0 0 auto; +} + +.scenario-card__voice-text { + width: 390px; + flex: 0 0 auto; +} + +.scenario-card__voice-type { + display: flex; + align-items: center; + gap: var(--space-3); + padding-top: var(--space-1); +} + +.scenario-card__hint { + font-size: var(--font-sm); + color: var(--color-text-muted); + margin: 0; +} + +.scenario-card__filename { + font-size: var(--font-sm); + color: var(--color-text-muted); + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scenario-card__upload-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + font-size: var(--font-sm); + color: var(--color-text); + white-space: nowrap; +} + +.scenario-card__upload-btn:hover { + background: var(--color-surface-raised); +} + +.scenario-card__file-input { + display: none; +} + +.scenario-card__hint-group { + display: inline-flex; + align-items: center; + gap: 0; +} + +.hint-icon { + color: var(--color-text-muted); + cursor: help; + flex: 0 0 auto; +} + +.radio-group__input { + accent-color: var(--color-accent-primary); +} diff --git a/frontend/src/features/character-editor/CharacterEditorPage.tsx b/frontend/src/features/character-editor/CharacterEditorPage.tsx index 47113b53..aca45d5c 100644 --- a/frontend/src/features/character-editor/CharacterEditorPage.tsx +++ b/frontend/src/features/character-editor/CharacterEditorPage.tsx @@ -8,6 +8,7 @@ import { deleteAllCharacterSprites, deleteCharacterMemory, deleteCharacterSprite, + deleteScenarioVoice, deleteSpriteVoice, exportCharacter, generateCharacterSetting, @@ -17,19 +18,25 @@ import { rememberCharacterMemory, saveCharacter, saveCharacterEmotionTags, + saveCharacterScenarios, + saveScenarioVoiceText, + saveScenarioVoiceType, saveSpriteScale, saveSpriteVoiceText, translateCharacterFields, uploadCharacterSprites, + uploadScenarioVoice, uploadSpriteVoice, } from "../../entities/character/repository"; -import type { Character, Sprite } from "../../entities/config/types"; +import type { Character, CharacterScenario, Sprite } from "../../entities/config/types"; import { fileUrl } from "../../entities/files/repository"; +import { getPlatform } from "../../shared/platform/platform"; import { baseName, numberedTags, tagContents } from "../../shared/assets/assetText"; import { DEFAULT_CHARACTER_COLOR } from "../../shared/constants"; import { useI18n } from "../../shared/i18n"; import { AlertDialog, PageSectionNav, useToast } from "../../shared/ui"; import { CharacterBasicSection } from "./CharacterBasicSection"; +import { CharacterScenarioSection } from "./CharacterScenarioSection"; import { CharacterMemorySection } from "./CharacterMemorySection"; import { CharacterPageHeader } from "./CharacterPageHeader"; import { CharacterPersonalitySection } from "./CharacterPersonalitySection"; @@ -339,6 +346,94 @@ export function CharacterEditorPage() { }, }); + // ---------- 情景模块 ---------- + + const [scenarioPendingVoicePath, setScenarioPendingVoicePath] = useState(""); + + const scenarioSaveMutation = useMutation({ + mutationFn: (scenarios: CharacterScenario[]) => saveCharacterScenarios(currentCharacterName, scenarios), + onError(error) { + showToast({ kind: "error", message: error instanceof Error ? error.message : "", title: t("character.voiceTag.saveAll") }); + }, + onSuccess(character) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: character.scenarios })); + showToast({ kind: "success", title: t("character.voiceTag.saveAll") }); + }, + }); + + const scenarioVoiceUploadMutation = useMutation({ + mutationFn: (input: { scenarioIndex: number; voicePath: string; voiceText: string; voiceType: string }) => + uploadScenarioVoice({ name: currentCharacterName, ...input }), + onError(error) { + showToast({ kind: "error", message: error instanceof Error ? error.message : "", title: t("character.voiceTag.uploadVoice") }); + }, + onSuccess(character) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: character.scenarios })); + showToast({ kind: "success", title: t("character.voiceTag.uploadVoice") }); + }, + }); + + const scenarioVoiceDeleteMutation = useMutation({ + mutationFn: ({ scenarioIndex }: { scenarioIndex: number }) => + deleteScenarioVoice(currentCharacterName, scenarioIndex), + onError(error) { + showToast({ kind: "error", message: error instanceof Error ? error.message : "", title: t("character.voiceTag.deleteVoice") }); + }, + onSuccess(character) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: character.scenarios })); + showToast({ kind: "success", title: t("character.voiceTag.deleteVoice") }); + }, + }); + + const scenarioVoiceTextMutation = useMutation({ + mutationFn: (input: { scenarioIndex: number; voiceText: string }) => + saveScenarioVoiceText({ name: currentCharacterName, ...input }), + onSuccess(character) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: character.scenarios })); + }, + }); + + const scenarioVoiceTypeMutation = useMutation({ + mutationFn: (input: { scenarioIndex: number; voiceType: string }) => + saveScenarioVoiceType({ name: currentCharacterName, ...input }), + onSuccess(character) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: character.scenarios })); + }, + }); + + const scenarioTranslateMutation = useMutation({ + mutationFn: async () => { + const names = (draft.scenarios ?? []).map((s) => s.name); + const result = await getPlatform().characters.translateScenarioNames(currentCharacterName, names); + const translated = result.translated ?? {}; + const updated = (draft.scenarios ?? []).map((s, i) => { + const t = translated[String(i)]; + return t ? { ...s, name: t } : s; + }); + if (Object.keys(translated).length > 0) { + await saveCharacterScenarios(currentCharacterName, updated); + } + return updated; + }, + onError(error) { + showToast({ kind: "error", message: error instanceof Error ? error.message : "", title: t("character.voiceTag.aiTranslate") }); + }, + onSuccess(updated) { + if (updated) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: updated })); + } + showToast({ kind: "success", title: t("character.voiceTag.aiTranslate") }); + }, + }); + + // ---------- 原有 ---------- + const spriteUploadMutation = useMutation({ mutationFn: async ({ character, @@ -822,6 +917,49 @@ export function CharacterEditorPage() { } }; + // ---------- 情绪模块 handlers ---------- + + const scenarioHandlers = { + addScenario() { + const scenarios = [...(draft.scenarios ?? []), { name: "", voice_type: "preset" as const }]; + setDraft((current) => ({ ...current, scenarios })); + }, + deleteScenario(si: number) { + const scenarios = (draft.scenarios ?? []).filter((_, i) => i !== si); + setDraft((current) => ({ ...current, scenarios })); + }, + updateScenarioName(si: number, name: string) { + const scenarios = (draft.scenarios ?? []).map((s, i) => (i === si ? { ...s, name } : s)); + setDraft((current) => ({ ...current, scenarios })); + }, + updateScenarioVoiceText(si: number, voiceText: string) { + const scenarios = (draft.scenarios ?? []).map((s, i) => (i === si ? { ...s, voice_text: voiceText } : s)); + setDraft((current) => ({ ...current, scenarios })); + }, + updateScenarioVoiceType(si: number, voiceType: string) { + scenarioVoiceTypeMutation.mutate({ scenarioIndex: si, voiceType }); + }, + uploadScenarioVoice(si: number, filePath: string) { + if (!filePath) return; + const s = draft.scenarios?.[si]; + scenarioVoiceUploadMutation.mutate({ + scenarioIndex: si, + voicePath: filePath, + voiceText: s?.voice_text ?? "", + voiceType: s?.voice_type ?? "preset", + }); + }, + saveVoiceText(si: number, voiceText: string) { + scenarioVoiceTextMutation.mutate({ scenarioIndex: si, voiceText }); + }, + deleteScenarioVoice(si: number) { + scenarioVoiceDeleteMutation.mutate({ scenarioIndex: si }); + }, + saveAllScenarios() { + scenarioSaveMutation.mutate(draft.scenarios ?? []); + }, + }; + return (
+ scenarioTranslateMutation.mutate()} + onDeleteScenario={scenarioHandlers.deleteScenario} + onSaveAllScenarios={scenarioHandlers.saveAllScenarios} + onSaveVoiceText={scenarioHandlers.saveVoiceText} + onScenarioNameChange={scenarioHandlers.updateScenarioName} + onScenarioVoiceTextChange={scenarioHandlers.updateScenarioVoiceText} + onScenarioVoiceTypeChange={scenarioHandlers.updateScenarioVoiceType} + onUploadScenarioVoice={scenarioHandlers.uploadScenarioVoice} + /> + void; + onAiTranslate: () => void; + onDeleteScenario: (index: number) => void; + onSaveVoiceText: (index: number, text: string) => void; + onScenarioNameChange: (index: number, name: string) => void; + onScenarioVoiceTextChange: (index: number, text: string) => void; + onScenarioVoiceTypeChange: (index: number, voiceType: string) => void; + onSaveAllScenarios: () => void; + onUploadScenarioVoice: (index: number, filePath: string) => void; +} + +function Hint({ text }: { text: string }) { + return ( + + + + ); +} + +export function CharacterScenarioSection({ + draft, + scenarioSavePending, + scenarioVoiceUploadPending, + translatePending, + onAddScenario, + onAiTranslate, + onDeleteScenario, + onSaveVoiceText, + onScenarioNameChange, + onScenarioVoiceTextChange, + onScenarioVoiceTypeChange, + onSaveAllScenarios, + onUploadScenarioVoice, +}: CharacterScenarioSectionProps) { + const { t } = useI18n(); + const scenarios: CharacterScenario[] = draft.scenarios ?? []; + + return ( +
+
+

{t("character.section.voiceTags")}

+
+ } + loading={translatePending} + onClick={onAiTranslate} + variant="ghost" + > + {t("character.voiceTag.aiTranslate")} + + + } + loading={scenarioSavePending} + onClick={onSaveAllScenarios} + variant="ghost" + > + {t("character.voiceTag.saveAll")} + +
+
+ +

{t("character.voiceTag.description")}

+

+ {t("character.voiceTag.voiceTypePreset")}:{t("character.voiceTag.voiceTypePresetHint")} · {t("character.voiceTag.voiceTypeReference")}:{t("character.voiceTag.voiceTypeReferenceHint")} +

+ + {scenarios.length === 0 && } + +
+ {scenarios.map((scenario, si) => { + const voiceType = scenario.voice_type ?? "preset"; + const voicePath = scenario.voice_path ?? ""; + return ( +
+
+ {t("character.voiceTag.index", { index: si + 1 })} + onScenarioNameChange(si, event.target.value)} + placeholder={t("character.voiceTag.namePlaceholder")} + value={scenario.name} + /> + + + onUploadScenarioVoice(si, path)} + pickLabel={t("character.voiceTag.uploadVoice")} + /> + + {voicePath ? ( + + ) : null} + onScenarioVoiceTextChange(si, event.target.value)} + placeholder={t("character.voiceTag.voiceText")} + value={scenario.voice_text ?? ""} + /> +
+ +
+ + + {voiceType === "reference" && ( + {t("character.voiceTag.voiceHint")} + )} +
+
+
+ ); + })} +
+ + ); +} diff --git a/frontend/src/features/character-editor/characterEditorUtils.ts b/frontend/src/features/character-editor/characterEditorUtils.ts index f2fd7c04..2e0a4796 100644 --- a/frontend/src/features/character-editor/characterEditorUtils.ts +++ b/frontend/src/features/character-editor/characterEditorUtils.ts @@ -20,6 +20,7 @@ export function createCharacter(): Character { character_setting: "", color: DEFAULT_CHARACTER_COLOR, emotion_tags: "", + scenarios: [], name: "", pronunciation_map: {}, speech_speed: 1, diff --git a/frontend/src/shared/i18n/messages.ts b/frontend/src/shared/i18n/messages.ts index 9e080d46..e55c7955 100644 --- a/frontend/src/shared/i18n/messages.ts +++ b/frontend/src/shared/i18n/messages.ts @@ -713,7 +713,42 @@ export type MessageKey = | "template.toast.saved" | "template.validation.backgroundRequired" | "template.validation.charactersRequired" - | "template.validation.nameRequired" + | "template.validation.nameRequired" | "character.section.voiceTags" + | "character.voiceTag.description" + | "character.voiceTag.add" + | "character.voiceTag.saveAll" + | "character.voiceTag.empty" + | "character.voiceTag.nameHint" + | "character.voiceTag.namePlaceholder" + | "character.voiceTag.voiceType" + | "character.voiceTag.voiceTypePreset" + | "character.voiceTag.voiceTypeReference" + | "character.voiceTag.uploadVoice" + | "character.voiceTag.deleteVoice" + | "character.voiceTag.voiceText" + | "character.voiceTag.voiceHint" + | "character.voiceTag.index" + | "character.voiceTag.aiTranslate" + | "character.voiceTag.voiceTypePresetHint" + | "character.voiceTag.voiceTypeReferenceHint" + | "character.section.voiceTags" + | "character.voiceTag.description" + | "character.voiceTag.aiTranslate" + | "character.voiceTag.add" + | "character.voiceTag.saveAll" + | "character.voiceTag.empty" + | "character.voiceTag.namePlaceholder" + | "character.voiceTag.nameHint" + | "character.voiceTag.voiceType" + | "character.voiceTag.voiceTypePreset" + | "character.voiceTag.voiceTypePresetHint" + | "character.voiceTag.voiceTypeReference" + | "character.voiceTag.voiceTypeReferenceHint" + | "character.voiceTag.uploadVoice" + | "character.voiceTag.deleteVoice" + | "character.voiceTag.voiceText" + | "character.voiceTag.voiceHint" + | "character.voiceTag.index" | "top.chatStage" | "tools.browse" | "tools.character" diff --git a/frontend/src/shared/i18n/messages/en.ts b/frontend/src/shared/i18n/messages/en.ts index 69984635..8075780a 100644 --- a/frontend/src/shared/i18n/messages/en.ts +++ b/frontend/src/shared/i18n/messages/en.ts @@ -774,4 +774,23 @@ export const enMessages: Record = { "template.validation.charactersRequired": "Choose at least one character.", "template.validation.nameRequired": "Template name is required.", "top.chatStage": "Chat stage", + "character.section.voiceTags": "Voice Trigger Tags", + "character.voiceTag.description": "Write short English tags in dialogue to trigger specific voices or switch reference timbre. e.g. [laugh], [whisper], [angry]", + "character.voiceTag.aiTranslate": "AI Translate", + "character.voiceTag.add": "Add tag", + "character.voiceTag.saveAll": "Save all tags", + "character.voiceTag.empty": "No tags configured. Click to add.", + "character.voiceTag.namePlaceholder": "Tag name", + "character.voiceTag.nameHint": "Short English tag, write [tagname] in dialogue to trigger, e.g. happy, kiss", + "character.voiceTag.voiceType": "Voice type", + "character.voiceTag.voiceTypePreset": "Preset", + "character.voiceTag.voiceTypePresetHint": "Play uploaded audio directly, bypass TTS", + "character.voiceTag.voiceTypeReference": "Reference", + "character.voiceTag.voiceTypeReferenceHint": "Use uploaded WAV (3-10s) as TTS reference timbre", + "character.voiceTag.uploadVoice": "Upload voice", + "character.voiceTag.deleteVoice": "Delete voice", + "character.voiceTag.voiceText": "Voice text", + "character.voiceTag.voiceHint": "Reference audio: WAV only, 3-10 seconds", + "character.voiceTag.index": "Tag {index}", + }; diff --git a/frontend/src/shared/i18n/messages/ja.ts b/frontend/src/shared/i18n/messages/ja.ts index 53745d8f..a690c5b2 100644 --- a/frontend/src/shared/i18n/messages/ja.ts +++ b/frontend/src/shared/i18n/messages/ja.ts @@ -772,4 +772,23 @@ export const jaMessages: Record = { "template.validation.charactersRequired": "キャラクターを 1 人以上選択してください。", "template.validation.nameRequired": "テンプレート名は必須です。", "top.chatStage": "チャットステージ", + "character.section.voiceTags": "音声トリガータグ", + "character.voiceTag.description": "台詞に英字の短いタグを書くと、指定の音声を再生するか、参照音色に切り替えます。例:[laugh]、[whisper]、[angry]", + "character.voiceTag.aiTranslate": "AI 翻訳", + "character.voiceTag.add": "タグを追加", + "character.voiceTag.saveAll": "すべて保存", + "character.voiceTag.empty": "タグが設定されていません", + "character.voiceTag.namePlaceholder": "タグ名", + "character.voiceTag.nameHint": "英字の短いタグ。台詞中に [タグ名] と書くと起動。例: happy, kiss", + "character.voiceTag.voiceType": "音声タイプ", + "character.voiceTag.voiceTypePreset": "プリセット", + "character.voiceTag.voiceTypePresetHint": "アップロード音声を直接再生、TTS をスキップ", + "character.voiceTag.voiceTypeReference": "参照", + "character.voiceTag.voiceTypeReferenceHint": "アップロードした WAV(3-10 秒)を TTS 参照音色に", + "character.voiceTag.uploadVoice": "音声をアップロード", + "character.voiceTag.deleteVoice": "音声を削除", + "character.voiceTag.voiceText": "音声テキスト", + "character.voiceTag.voiceHint": "参照音声は WAV のみ、3〜10秒", + "character.voiceTag.index": "タグ {index}", + }; diff --git a/frontend/src/shared/i18n/messages/zh_CN.ts b/frontend/src/shared/i18n/messages/zh_CN.ts index c6f21718..8b41581e 100644 --- a/frontend/src/shared/i18n/messages/zh_CN.ts +++ b/frontend/src/shared/i18n/messages/zh_CN.ts @@ -760,4 +760,23 @@ export const zhCNMessages: Record = { "template.validation.charactersRequired": "请至少选择一个角色。", "template.validation.nameRequired": "模板名不能为空。", "top.chatStage": "聊天舞台", + "character.section.voiceTags": "语音触发标签", + "character.voiceTag.description": "在台词中写入英文短标签,触发指定语音,或切换到对应的参考音色。例如 [laugh]、[whisper]、[angry],系统检测到后执行对应规则。", + "character.voiceTag.aiTranslate": "AI 翻译", + "character.voiceTag.add": "添加标签", + "character.voiceTag.saveAll": "保存所有标签", + "character.voiceTag.empty": "暂无标签,点击添加标签开始", + "character.voiceTag.namePlaceholder": "标签名", + "character.voiceTag.nameHint": "英文短标签,在台词中写作 [标签名] 即可触发,如 happy、kiss", + "character.voiceTag.voiceType": "语音类型", + "character.voiceTag.voiceTypePreset": "预设", + "character.voiceTag.voiceTypePresetHint": "直接播放上传的音频文件,跳过 TTS 合成", + "character.voiceTag.voiceTypeReference": "参考", + "character.voiceTag.voiceTypeReferenceHint": "将上传的 WAV(3-10 秒)作为 TTS 参考音色", + "character.voiceTag.uploadVoice": "上传语音", + "character.voiceTag.deleteVoice": "删除语音", + "character.voiceTag.voiceText": "语音文本", + "character.voiceTag.voiceHint": "参考语音仅支持 WAV 格式,时长 3-10 秒", + "character.voiceTag.index": "标签 {index}", + }; diff --git a/frontend/src/shared/platform/browserPreviewPlatform.ts b/frontend/src/shared/platform/browserPreviewPlatform.ts index f95e98ff..75e35506 100644 --- a/frontend/src/shared/platform/browserPreviewPlatform.ts +++ b/frontend/src/shared/platform/browserPreviewPlatform.ts @@ -692,6 +692,47 @@ export function createBrowserPreviewPlatform(): ShinsekaiPlatform { }; return delay(character); }, + async saveScenarios(name, scenarios) { + const character = config.characters.find((item) => item.name === name); + if (!character) throw new Error("找不到角色。"); + character.scenarios = scenarios; + return delay(character); + }, + async uploadScenarioVoice(input) { + const character = config.characters.find((item) => item.name === input.name); + if (!character) throw new Error("找不到角色。"); + if (!character.scenarios) character.scenarios = []; + if (!character.scenarios[input.scenarioIndex]) throw new Error("情景不存在。"); + character.scenarios[input.scenarioIndex] = { + ...character.scenarios[input.scenarioIndex], + voice_path: input.voicePath, + voice_text: input.voiceText, + voice_type: input.voiceType as "preset" | "reference", + }; + return delay(character); + }, + async deleteScenarioVoice(name, scenarioIndex) { + const character = config.characters.find((item) => item.name === name); + if (!character || !character.scenarios?.[scenarioIndex]) throw new Error("找不到。"); + const s = character.scenarios[scenarioIndex]; + s.voice_path = undefined; + s.voice_text = undefined; + s.voice_type = undefined; + return delay(character); + }, + async saveScenarioVoiceText(input) { + const character = config.characters.find((item) => item.name === input.name); + if (!character || !character.scenarios?.[input.scenarioIndex]) throw new Error("找不到。"); + character.scenarios[input.scenarioIndex].voice_text = input.voiceText; + return delay(character); + }, + async translateScenarioNames(_name, _names) { return { translated: {} }; }, + async saveScenarioVoiceType(input) { + const character = config.characters.find((item) => item.name === input.name); + if (!character || !character.scenarios?.[input.scenarioIndex]) throw new Error("找不到。"); + character.scenarios[input.scenarioIndex].voice_type = input.voiceType as "preset" | "reference"; + return delay(character); + }, }, config: { async cancelTtsBundleDownload(taskId) { diff --git a/frontend/src/shared/platform/httpPlatform.ts b/frontend/src/shared/platform/httpPlatform.ts index 4f056eed..c078981f 100644 --- a/frontend/src/shared/platform/httpPlatform.ts +++ b/frontend/src/shared/platform/httpPlatform.ts @@ -469,6 +469,32 @@ export function createHttpPlatform(baseUrl: string): ShinsekaiPlatform { body: JSON.stringify(input), method: "POST", }), + saveScenarios: (name, scenarios) => + requestJson(apiBase, "/api/characters/scenarios/save", { + body: JSON.stringify({ name, scenarios }), + method: "POST", + }), + uploadScenarioVoice: (input) => + requestJson(apiBase, "/api/characters/scenarios/voice-upload", { + body: JSON.stringify(input), + method: "POST", + }), + deleteScenarioVoice: (name, scenarioIndex) => + requestJson(apiBase, "/api/characters/scenarios/voice-delete", { + body: JSON.stringify({ name, scenarioIndex }), + method: "POST", + }), + saveScenarioVoiceText: (input) => + requestJson(apiBase, "/api/characters/scenarios/voice-text", { + body: JSON.stringify(input), + method: "POST", + }), + translateScenarioNames: (name, names) => requestJson(apiBase, "/api/characters/scenarios/translate-names", { body: JSON.stringify({ name, names }), method: "POST" }), + saveScenarioVoiceType: (input) => + requestJson(apiBase, "/api/characters/scenarios/voice-type", { + body: JSON.stringify(input), + method: "POST", + }), }, config: { cancelTtsBundleDownload: (taskId) => diff --git a/frontend/src/shared/platform/types.ts b/frontend/src/shared/platform/types.ts index 0b726adf..063f6326 100644 --- a/frontend/src/shared/platform/types.ts +++ b/frontend/src/shared/platform/types.ts @@ -6,6 +6,13 @@ export interface Sprite { voice_text?: string; } +export interface CharacterScenario { + name: string; + voice_path?: string; + voice_type?: "preset" | "reference" | null; + voice_text?: string; +} + export interface Character { name: string; color: string; @@ -14,6 +21,7 @@ export interface Character { character_setting: string; sprite_scale: number; emotion_tags: string; + scenarios?: CharacterScenario[]; gpt_model_path?: string; sovits_model_path?: string; refer_audio_path?: string; @@ -818,6 +826,18 @@ export interface ShinsekaiPlatform { voicePath: string; voiceText: string; }) => Promise; + saveScenarios: (name: string, scenarios: CharacterScenario[]) => Promise; + uploadScenarioVoice: (input: { + name: string; + scenarioIndex: number; + voicePath: string; + voiceText: string; + voiceType: string; + }) => Promise; + deleteScenarioVoice: (name: string, scenarioIndex: number) => Promise; + saveScenarioVoiceText: (input: { name: string; scenarioIndex: number; voiceText: string }) => Promise; + translateScenarioNames: (name: string, names: string[]) => Promise<{ translated: Record }>; + saveScenarioVoiceType: (input: { name: string; scenarioIndex: number; voiceType: string }) => Promise; }; config: { cancelTtsBundleDownload: (taskId: string) => Promise>; diff --git a/frontend/src/test/characterEditorUtils.test.ts b/frontend/src/test/characterEditorUtils.test.ts index 62b5f355..f87a3f26 100644 --- a/frontend/src/test/characterEditorUtils.test.ts +++ b/frontend/src/test/characterEditorUtils.test.ts @@ -17,6 +17,7 @@ describe("character editor utilities", () => { character_setting: "", color: DEFAULT_CHARACTER_COLOR, emotion_tags: "", + scenarios: [], name: "", pronunciation_map: {}, speech_speed: 1, diff --git a/frontend/src/test/features/character-editor/characterEditorUtils.test.ts b/frontend/src/test/features/character-editor/characterEditorUtils.test.ts index 432c953e..5cf34896 100644 --- a/frontend/src/test/features/character-editor/characterEditorUtils.test.ts +++ b/frontend/src/test/features/character-editor/characterEditorUtils.test.ts @@ -16,6 +16,7 @@ describe("character editor utilities", () => { character_setting: "", color: DEFAULT_CHARACTER_COLOR, emotion_tags: "", + scenarios: [], name: "", pronunciation_map: {}, speech_speed: 1, diff --git a/frontend/src/test/repositories.test.ts b/frontend/src/test/repositories.test.ts index 726e4ff4..408f4a53 100644 --- a/frontend/src/test/repositories.test.ts +++ b/frontend/src/test/repositories.test.ts @@ -266,6 +266,12 @@ describe("entity repositories", () => { saveSpriteVoiceText: vi.fn().mockResolvedValue(character), translateFields: vi.fn().mockResolvedValue({ characterSetting: "kind", emotionTags: "happy", name: "Nanami" }), uploadSpriteVoice: vi.fn().mockResolvedValue(character), + saveScenarios: vi.fn().mockResolvedValue(character), + uploadScenarioVoice: vi.fn().mockResolvedValue(character), + deleteScenarioVoice: vi.fn().mockResolvedValue(character), + saveScenarioVoiceText: vi.fn().mockResolvedValue(character), + translateScenarioNames: vi.fn().mockResolvedValue({ translated: {} }), + saveScenarioVoiceType: vi.fn().mockResolvedValue(character), uploadSprites: vi.fn().mockResolvedValue(character), }, }; diff --git a/frontend_bridge_core/characters.py b/frontend_bridge_core/characters.py index 1ee5d721..800d69ad 100644 --- a/frontend_bridge_core/characters.py +++ b/frontend_bridge_core/characters.py @@ -270,3 +270,130 @@ def _delete_sprite_voice(state: BridgeState, payload: dict[str, Any]) -> dict[st if message.startswith("找不到") or message.startswith("立绘不存在") or message.startswith("请先"): raise RuntimeError(message) return _character_json_after_reload(state, name) + + +# ---------- 情景模块 ---------- + + +def _validate_reference_audio(voice_path: str) -> None: + """参考音频校验:仅限 WAV 格式,时长 3-10 秒""" + from sdk.ui.validators import audio_duration_between + + ext = Path(voice_path).suffix.lower() + if ext != ".wav": + raise ValueError("参考语音仅支持 WAV 格式") + ok, err = audio_duration_between(voice_path, 3.0, 10.0, "参考语音") + if not ok: + raise ValueError(err) + + +def _list_character_scenarios(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + scenarios = state.character_manager.get_character_scenarios(name) + return {"scenarios": scenarios, "name": name} + + +def _save_character_scenarios(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + scenarios = payload.get("scenarios") or [] + message = state.character_manager.save_character_scenarios(name, scenarios) + if message.startswith("找不到") or message.startswith("请先"): + raise RuntimeError(message) + return _character_json_after_reload(state, name) + + +def _upload_scenario_voice(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + scenario_index = int(payload.get("scenarioIndex") or 0) + voice_path = str(payload.get("voicePath") or "").strip() + voice_text = str(payload.get("voiceText") or "").strip() + voice_type = str(payload.get("voiceType") or "").strip().lower() + if not voice_path: + raise ValueError("voice path is required") + if voice_type == "reference": + _validate_reference_audio(voice_path) + elif voice_type != "preset" and voice_text.strip(): + _validate_sprite_voice_duration(voice_path, voice_text) + message, _path = state.character_manager.upload_scenario_voice( + name, scenario_index, voice_path, voice_text, voice_type, + ) + if message.startswith("找不到") or message.startswith("情景不存在") or message.startswith("请先") or message.startswith("请选择"): + raise RuntimeError(message) + return _character_json_after_reload(state, name) + + +def _delete_scenario_voice(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + scenario_index = int(payload.get("scenarioIndex") or 0) + message = state.character_manager.delete_scenario_voice(name, scenario_index) + if message.startswith("找不到") or message.startswith("情景不存在"): + raise RuntimeError(message) + return _character_json_after_reload(state, name) + + +def _save_scenario_voice_text(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + scenario_index = int(payload.get("scenarioIndex") or 0) + voice_text = str(payload.get("voiceText") or "").strip() + message = state.character_manager.save_scenario_voice_text(name, scenario_index, voice_text) + if message.startswith("找不到"): + raise RuntimeError(message) + return _character_json_after_reload(state, name) + + +def _save_scenario_voice_type(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + name = str(payload.get("name") or "").strip() + scenario_index = int(payload.get("scenarioIndex") or 0) + voice_type = str(payload.get("voiceType") or "").strip().lower() + if not voice_type: + raise ValueError("voice type is required") + message = state.character_manager.save_scenario_voice_type(name, scenario_index, voice_type) + if message.startswith("找不到"): + raise RuntimeError(message) + return _character_json_after_reload(state, name) + + +def _translate_scenario_names(state: BridgeState, payload: dict[str, Any]) -> dict[str, Any]: + """AI translate scenario tag names to English.""" + name = str(payload.get("name") or "").strip() + if not name: + raise ValueError("name is required") + character = state.character_manager._config_manager.get_character_by_name(name) + if not character: + raise KeyError(f"character not found: {name}") + scenarios = getattr(character, "scenarios", []) or [] + if not scenarios: + return {"translated": {}} + import re + needs = [] + for i, s in enumerate(scenarios): + sname = s.name if hasattr(s, "name") else s.get("name", "") + if sname and not re.match(r"^[a-zA-Z0-9_]+$", sname): + needs.append((i, sname)) + if not needs: + return {"translated": {}} + joined = "\n".join([f"{idx+1}. {n}" for idx, n in needs]) + prompt = "Translate these labels to English (one word each, lowercase). Return ONLY a JSON object with key 'translations' as array of strings.\n\n" + joined + try: + cm = state.character_manager._config_manager + llm_provider, llm_model, llm_base_url, api_key = cm.get_llm_api_config() + if not llm_provider or not api_key or not llm_model: + return {"translated": {}, "error": "LLM not configured"} + from llm.llm_manager import LLMAdapterFactory + kwargs = cm.merged_llm_factory_kwargs( + llm_provider, + {"llm_provider": llm_provider, "api_key": api_key, "base_url": llm_base_url, "model": llm_model}, + ) + adapter = LLMAdapterFactory.create_adapter(**kwargs) + raw = adapter.chat([{"role": "user", "content": prompt}], max_tokens=100) + text = raw.choices[0].message.content if hasattr(raw, "choices") else str(raw) + import json as j + data = j.loads(text) + trans = data.get("translations", []) + except Exception: + trans = [] + result = {} + for idx, (orig_i, _) in enumerate(needs): + if idx < len(trans) and trans[idx]: + result[str(orig_i)] = trans[idx].strip().lower() + return {"translated": result} diff --git a/frontend_bridge_core/handler.py b/frontend_bridge_core/handler.py index 26f376db..f8f433a3 100644 --- a/frontend_bridge_core/handler.py +++ b/frontend_bridge_core/handler.py @@ -42,15 +42,22 @@ _delete_all_character_sprites, _delete_character_memory, _delete_character_sprite, + _delete_scenario_voice, _delete_sprite_voice, _generate_character_setting, _list_character_memories, + _list_character_scenarios, _save_character, _save_character_emotion_tags, + _save_character_scenarios, + _save_scenario_voice_text, + _save_scenario_voice_type, + _translate_scenario_names, _save_sprite_scale, _save_sprite_voice_text, _translate_character_fields, _upload_character_sprites, + _upload_scenario_voice, _upload_sprite_voice, ) from .config import _app_config_response, _fetch_llm_models, _save_api_config, _test_llm_connection @@ -495,6 +502,20 @@ def _handle_write(self, method: str) -> None: self._send_json(_save_sprite_voice_text(self.state, body)) elif method == "POST" and path == "/api/characters/sprite-voice/delete": self._send_json(_delete_sprite_voice(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/list": + self._send_json(_list_character_scenarios(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/save": + self._send_json(_save_character_scenarios(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/voice-upload": + self._send_json(_upload_scenario_voice(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/voice-delete": + self._send_json(_delete_scenario_voice(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/voice-text": + self._send_json(_save_scenario_voice_text(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/voice-type": + self._send_json(_save_scenario_voice_type(self.state, body)) + elif method == "POST" and path == "/api/characters/scenarios/translate-names": + self._send_json(_translate_scenario_names(self.state, body)) elif method == "DELETE" and path.startswith("/api/characters/"): name = unquote(path.rsplit("/", 1)[-1]) message, names = self.state.character_manager.delete_character(name) From 65d37316211c2bb4d3c99e6f6eb37890b9bfe485 Mon Sep 17 00:00:00 2001 From: V1per-cmyk Date: Sun, 14 Jun 2026 13:54:28 +0800 Subject: [PATCH 2/5] =?UTF-8?q?chore:=20=E8=AF=AD=E9=9F=B3=E8=A7=A6?= =?UTF-8?q?=E5=8F=91=E6=A0=87=E7=AD=BE=20UI=20=E6=A0=BC=E5=BC=8F=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CharacterScenarioSection.tsx | 41 +++++++++---------- frontend/src/shared/i18n/messages.ts | 2 + frontend/src/shared/i18n/messages/en.ts | 2 +- frontend/src/shared/i18n/messages/ja.ts | 2 +- frontend/src/shared/i18n/messages/zh_CN.ts | 2 +- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/frontend/src/features/character-editor/CharacterScenarioSection.tsx b/frontend/src/features/character-editor/CharacterScenarioSection.tsx index abaebf9b..fb1aa077 100644 --- a/frontend/src/features/character-editor/CharacterScenarioSection.tsx +++ b/frontend/src/features/character-editor/CharacterScenarioSection.tsx @@ -76,7 +76,7 @@ export function CharacterScenarioSection({

{t("character.voiceTag.description")}

- {t("character.voiceTag.voiceTypePreset")}:{t("character.voiceTag.voiceTypePresetHint")} · {t("character.voiceTag.voiceTypeReference")}:{t("character.voiceTag.voiceTypeReferenceHint")} + {t("character.voiceTag.voiceTypePreset")}:{t("character.voiceTag.voiceTypePresetHint")} · {t("character.voiceTag.voiceTypeReference")}:{t("character.voiceTag.voiceTypeReferenceHint")} · {t("character.voiceTag.saveBeforeTranslate")}

{scenarios.length === 0 && } @@ -105,32 +105,18 @@ export function CharacterScenarioSection({ /> {voicePath ? ( - + + {voicePath.split("/").pop()?.split("\\").pop()} + + + ) : null} - onScenarioVoiceTextChange(si, event.target.value)} - placeholder={t("character.voiceTag.voiceText")} - value={scenario.voice_text ?? ""} - /> -