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..f97752a9 100644 --- a/config/character_manager.py +++ b/config/character_manager.py @@ -400,9 +400,14 @@ def delete_all_sprites(self, character_name: str) -> Tuple[str, List[str], str]: # 清空角色属性 character.sprites = [] character.emotion_tags = "" - + # 场景语音也放在同一个 VOICE_DIR 下,目录已删,YAML 引用必须同步清空 + if hasattr(character, 'scenarios'): + for _sc in (character.scenarios or []): + _sc.voice_path = None + character.scenarios = [] + self._config_manager.save_characters_config() - + return f"已删除 {character_name} 的所有立绘!", [], "" @@ -646,6 +651,124 @@ 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 + # 用场景名的 hash 做文件名,避免删标签后索引复用覆盖旧音频 + import re as _re + _safe = _re.sub(r'[^\w]', '_', scenario.name or f"tag{scenario_index}")[:40] + voice_filename = f"scenario_{_safe}{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] + # 先持久化清空引用,再删文件;防止中途崩溃导致 YAML 残留失效路径 + old_voice_path = scenario.voice_path + scenario.voice_path = None + scenario.voice_text = None + scenario.voice_type = None + self._config_manager.save_characters_config() + if old_voice_path: + try: + os.remove(old_voice_path) + except OSError: + pass + 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..030e5a3a 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,46 @@ def get_character_by_name(name: str): return _config.get_character_by_name(name) +def _read_scenarios_from_yaml(name_s: str) -> list: + """直接从 YAML 读取角色的 scenarios,避免跨进程缓存。""" + try: + _chars_path = Path("data/config/characters.yaml") + if not _chars_path.is_file(): + return [] + with open(_chars_path, "r", encoding="utf-8") as _fh: + _data = yaml.safe_load(_fh) or [] + for _c in _data: + if _c.get("name") == name_s: + _scenarios = _c.get("scenarios") or [] + # 归一化 voice_type 为小写,防御旧数据大小写不一致 + for _s in _scenarios: + if isinstance(_s, dict) and _s.get("voice_type"): + _s["voice_type"] = str(_s["voice_type"]).strip().lower() + return _scenarios + except Exception: + pass + return [] + + +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) @@ -135,10 +176,42 @@ def handle(self, msg: LLMDialogMessage) -> None: asset_id = msg.asset_id text_processor = rt.text_processor speech_text = speech + + # 语音触发标签:[tagname] 匹配角色情景,触发对应语音 + # 必须在翻译处理之前匹配,因为标签只存在于原始 speech 中 + _tag_ref_audio = None + _tag_prompt = None + _scenarios = _read_scenarios_from_yaml(name_s) + _matched, _clean_speech = _match_voice_tag(speech_text, _scenarios) + if _matched: + _m_name = _matched.name if hasattr(_matched, 'name') else _matched.get('name', '?') + _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') + print(f"[VoiceTag] 匹配标签 [{_m_name}] type={_m_vt} vp={_m_vp}") + if _m_vt == "preset" and _m_vp: + print(f"[VoiceTag] 预设语音:跳过 TTS,直接播放 {_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: + print(f"[VoiceTag] 参考语音:用 {_m_vp} 作为 TTS 参考音色") + speech_text = _clean_speech + _tag_ref_audio = _m_vp + _tag_prompt = _m_vtext or "" + if translate: text_processor = None speech_text = rt.text_processor.remove_parentheses(translate) speech_text = rt.text_processor.replace_names(speech_text) + + # 剥离语音标签 [xxx],不显示在台词中 + speech_text = re.sub(r'\s*\[\w+\]\s*', ' ', speech_text).strip() + audio_path = "" if rt.tts_manager: _post_tts_busy(tr_i18n("desktop.tts_busy_synthesizing", name=name_s)) @@ -161,11 +234,23 @@ def handle(self, msg: LLMDialogMessage) -> None: prompt_text = character_config.prompt_text try: sprite_data = character_config.sprites[sprite_id] - if sprite_data.get("voice_text", None): - ref_audio_path = Path(sprite_data.get("voice_path")).resolve().as_posix() - prompt_text = sprite_data.get("voice_text") + if isinstance(sprite_data, dict): + _vt = sprite_data.get("voice_text") + else: + _vt = getattr(sprite_data, "voice_text", None) + if _vt: + if isinstance(sprite_data, dict): + ref_audio_path = Path(sprite_data.get("voice_path") or "").resolve().as_posix() + else: + ref_audio_path = Path(getattr(sprite_data, "voice_path", "") or "").resolve().as_posix() + prompt_text = _vt 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/core/handlers/ui_message_handler.py b/core/handlers/ui_message_handler.py index 16024e40..fc40bcc0 100644 --- a/core/handlers/ui_message_handler.py +++ b/core/handlers/ui_message_handler.py @@ -173,7 +173,8 @@ def handle(self, out: TTSOutputMessage) -> None: ui.hide_busy_bar() ch = _play() character_name = out.name - speech = out.text or "" + import re + speech = re.sub(r'\s*\[\w+\]\s*', '', out.text or "").strip() sprite_id = out.asset_id audio_path = out.audio_path if audio_path: 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..45d0560a 100644 --- a/frontend/src/features/character-editor/CharacterEditorPage.css +++ b/frontend/src/features/character-editor/CharacterEditorPage.css @@ -247,3 +247,135 @@ 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); +} + +.save-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-accent-primary); + margin-left: 4px; +} + +.scenario-card__uploading { + font-size: var(--font-sm); + color: var(--color-text-muted); + padding: 4px 8px; +} diff --git a/frontend/src/features/character-editor/CharacterEditorPage.tsx b/frontend/src/features/character-editor/CharacterEditorPage.tsx index 47113b53..718176bd 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,122 @@ 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 }); + // 只合并服务器更新的字段,保留本地未保存的 name 等编辑 + setDraft((current) => { + const serverScenarios = character.scenarios ?? []; + return { + ...current, + scenarios: (current.scenarios ?? []).map((local, i) => { + const server = serverScenarios[i]; + if (!server) return local; + return { ...local, voice_text: server.voice_text, voice_type: server.voice_type, voice_path: server.voice_path }; + }), + }; + }); + }, + }); + + const scenarioVoiceTypeMutation = useMutation({ + mutationFn: (input: { scenarioIndex: number; voiceType: string }) => + saveScenarioVoiceType({ name: currentCharacterName, ...input }), + onSuccess(character) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + // 只合并服务器更新的 voice_type/voice_path,保留本地未保存的 name/voice_text + setDraft((current) => { + const serverScenarios = character.scenarios ?? []; + return { + ...current, + scenarios: (current.scenarios ?? []).map((local, i) => { + const server = serverScenarios[i]; + if (!server) return local; + return { ...local, voice_type: server.voice_type, voice_path: server.voice_path }; + }), + }; + }); + }, + }); + + const scenarioTranslateMutation = useMutation({ + mutationFn: async () => { + const names = (draft.scenarios ?? []).map((s) => s.name); + const result = await getPlatform().characters.translateScenarioNames(currentCharacterName, names); + // 桥接可能返回 error 字段,需要抛出让 onError 捕获 + if (result.error) { + throw new Error(result.error); + } + 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, hasTranslations: Object.keys(translated).length > 0 }; + }, + onError(error) { + showToast({ kind: "error", message: error instanceof Error ? error.message : "", title: t("character.voiceTag.aiTranslate") }); + }, + onSuccess(result) { + if (result?.hasTranslations) { + queryClient.invalidateQueries({ queryKey: charactersQueryKey }); + setDraft((current) => ({ ...current, scenarios: result.updated })); + showToast({ kind: "success", title: t("character.voiceTag.aiTranslate") }); + } else { + showToast({ kind: "info", message: t("character.voiceTag.aiTranslateEmpty"), title: t("character.voiceTag.aiTranslate") }); + } + }, + }); + + // ---------- 原有 ---------- + const spriteUploadMutation = useMutation({ mutationFn: async ({ character, @@ -822,6 +945,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, + hasUnsavedChanges, + isSavedCharacter, + scenarioSavePending, + translatePending, + onAddScenario, + onAiTranslate, + onDeleteScenario, + onSaveVoiceText, + onScenarioNameChange, + onScenarioVoiceTextChange, + onScenarioVoiceTypeChange, + onSaveAllScenarios, + onUploadScenarioVoice, +}: CharacterScenarioSectionProps) { + const { t } = useI18n(); + const scenarios: CharacterScenario[] = draft.scenarios ?? []; + const [pendingDeleteIndex, setPendingDeleteIndex] = useState(null); + + return ( +
+
+

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

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

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

+

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

+ + {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 ? ( + <> + + {voicePath.split("/").pop()?.split("\\").pop()} + + + + ) : null} +
+ + + {voiceType === "reference" && ( + {t("character.voiceTag.voiceHint")} + )} +
+ onScenarioVoiceTextChange(si, event.target.value)} + placeholder={t("character.voiceTag.voiceText")} + value={scenario.voice_text ?? ""} + /> +
+
+
+
+ ); + })} +
+ + {pendingDeleteIndex !== null && ( + setPendingDeleteIndex(null)} + onConfirm={() => { + onDeleteScenario(pendingDeleteIndex); + setPendingDeleteIndex(null); + }} + open + title={t("character.voiceTag.deleteConfirmTitle")} + /> + )} +
+ ); +} 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..7eadaa43 100644 --- a/frontend/src/shared/i18n/messages.ts +++ b/frontend/src/shared/i18n/messages.ts @@ -713,7 +713,50 @@ 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.deleteConfirmTitle" + | "character.voiceTag.deleteConfirmBody" + | "character.voiceTag.uploading" + | "character.voiceTag.saveBeforeTranslate" + | "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.deleteConfirmTitle" + | "character.voiceTag.deleteConfirmBody" + | "character.voiceTag.uploading" + | "character.voiceTag.saveBeforeTranslate" + | "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..d8e62eb2 100644 --- a/frontend/src/shared/i18n/messages/en.ts +++ b/frontend/src/shared/i18n/messages/en.ts @@ -774,4 +774,28 @@ 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.aiTranslateEmpty": "No tag names to 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}", + "character.voiceTag.saveBeforeTranslate": "Please save tags before using AI translate", + "character.voiceTag.deleteConfirmTitle": "Confirm delete", + "character.voiceTag.deleteConfirmBody": "Delete tag {index}? This cannot be undone.", + "character.voiceTag.uploading": "Uploading...", + }; diff --git a/frontend/src/shared/i18n/messages/ja.ts b/frontend/src/shared/i18n/messages/ja.ts index 53745d8f..a4cacb82 100644 --- a/frontend/src/shared/i18n/messages/ja.ts +++ b/frontend/src/shared/i18n/messages/ja.ts @@ -772,4 +772,28 @@ 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.aiTranslateEmpty": "翻訳できるタグ名がありません", + "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}", + "character.voiceTag.saveBeforeTranslate": "AI翻訳を使用する前にタグを保存してください", + "character.voiceTag.deleteConfirmTitle": "削除確認", + "character.voiceTag.deleteConfirmBody": "タグ {index} を削除しますか?この操作は元に戻せません。", + "character.voiceTag.uploading": "アップロード中...", + }; diff --git a/frontend/src/shared/i18n/messages/zh_CN.ts b/frontend/src/shared/i18n/messages/zh_CN.ts index c6f21718..d0655014 100644 --- a/frontend/src/shared/i18n/messages/zh_CN.ts +++ b/frontend/src/shared/i18n/messages/zh_CN.ts @@ -760,4 +760,28 @@ 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.aiTranslateEmpty": "没有可翻译的标签名称", + "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}", + "character.voiceTag.saveBeforeTranslate": "请保存标签后再使用 AI 翻译功能", + "character.voiceTag.deleteConfirmTitle": "确认删除", + "character.voiceTag.deleteConfirmBody": "确定要删除标签 {index} 吗?此操作不可撤销。", + "character.voiceTag.uploading": "上传中...", + }; 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..33e97d43 100644 --- a/frontend_bridge_core/characters.py +++ b/frontend_bridge_core/characters.py @@ -91,6 +91,13 @@ def _save_character(state: BridgeState, payload: dict[str, Any]) -> dict[str, An ) if message.startswith("名称不能为空") or "已与其他角色重复" in message or message.startswith("保存失败"): raise RuntimeError(message) + # 保存 scenarios(语音触发标签),新建角色时不能丢 + _scenarios = body.get('scenarios', []) if isinstance(body, dict) else [] + if _scenarios: + try: + state.character_manager.save_character_scenarios(saved_name, _scenarios) + except Exception: + pass state.config_manager.reload() saved = state.config_manager.get_character_by_name(saved_name) return _jsonify(saved or character) @@ -270,3 +277,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) diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 26c8b885..ad891547 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -696,6 +696,9 @@ "tools_item": "- **{name}**: {description}", "tools_param_summary": " (parameters: {summary}; * = required)", "closing": "\nBegin the scene: describe the situation and what the user is doing; {extra}then continue:\n", - "closing_extra_bgm": "set initial scene and BGM, " + "closing_extra_bgm": "set initial scene and BGM, ", + "voice_tags_header": "\nVoice trigger tags:\n", + "voice_tags_for": "{name} tags: {tags}\n", + "r_voice_tag": "Voice tags: Automatically insert matching voice tags in speech based on emotion/tone, format [tagname]. e.g. [laugh]Hello or so sad[cry]. Use sparingly, not every line." } -} +} \ No newline at end of file diff --git a/i18n/locales/ja.json b/i18n/locales/ja.json index fbf4ddb8..08a40e5c 100644 --- a/i18n/locales/ja.json +++ b/i18n/locales/ja.json @@ -693,6 +693,9 @@ "tools_item": "- **{name}**:{description}", "tools_param_summary": "(引数:{summary};* は必須)", "closing": "\n会話を開始。状況とユーザーの行動を説明し{extra}続けてください:\n", - "closing_extra_bgm": "初期のシーンと BGM を設定し、" + "closing_extra_bgm": "初期のシーンと BGM を設定し、", + "voice_tags_header": "\n音声トリガータグ:\n", + "voice_tags_for": "{name} のタグ:{tags}\n", + "r_voice_tag": "音声タグ:セリフの中で、感情やトーンに合わせて [タグ名] の形式で音声タグを挿入してください。例:[laugh]こんにちは または 悲しい[cry]。適度に使用し、毎回入れないでください。" } -} +} \ No newline at end of file diff --git a/i18n/locales/zh_CN.json b/i18n/locales/zh_CN.json index 09ec9fbc..c074f9f7 100644 --- a/i18n/locales/zh_CN.json +++ b/i18n/locales/zh_CN.json @@ -693,6 +693,9 @@ "tools_item": "- **{name}**:{description}", "tools_param_summary": "(参数:{summary};带 * 者为必填)", "closing": "\n请开始对话, 开始时介绍下用户所处的情境和背景, {extra} 以及在做什么事情: \n", - "closing_extra_bgm": "设置初始的场景和bgm, " + "closing_extra_bgm": "设置初始的场景和bgm, ", + "voice_tags_header": "\n语音触发标签:\n", + "voice_tags_for": "{name} 可用标签:{tags}\n", + "r_voice_tag": "语音标签:在 speech 台词中,根据当前情绪或语气自动插入对应的语音标签,格式为 [标签名]。例如 [laugh]你好啊 或 好难过[cry]。请合理使用,不要每句都加。" } -} +} \ No newline at end of file diff --git a/llm/template_generator.py b/llm/template_generator.py index 892430b0..ced80e4f 100644 --- a/llm/template_generator.py +++ b/llm/template_generator.py @@ -359,6 +359,23 @@ def generate_chat_template( template += _T("sprites_count", name=char_name, n=len(char_detail.sprites)) template += f"{char_detail.emotion_tags}\n\n" + # 语音触发标签 + voice_tags = [] + for char_name in selected_characters: + char_detail = config_manager.get_character_by_name(char_name) + scenarios = getattr(char_detail, 'scenarios', []) or [] + tag_names = [] + for s in scenarios: + sn = s.name if hasattr(s, 'name') else s.get('name', '') + if sn: + tag_names.append(sn) + if tag_names: + voice_tags.append((char_name, tag_names)) + if voice_tags: + template += _T("voice_tags_header") + for char_name, tag_names in voice_tags: + template += _T("voice_tags_for", name=char_name, tags=", ".join(tag_names)) + template += _T("profile_header") for char_name in selected_characters: char_detail = config_manager.get_character_by_name(char_name) @@ -422,6 +439,7 @@ def generate_chat_template( 20, ), RequirementSpec("r_sprite", _T("r_sprite"), 30), + RequirementSpec("r_voice_tag", _T("r_voice_tag"), 31), RequirementSpec( "r_non_sprite", _T( diff --git a/tools/file_util.py b/tools/file_util.py index 2c4d8954..40a2767e 100644 --- a/tools/file_util.py +++ b/tools/file_util.py @@ -153,6 +153,7 @@ def export_character(character_configs: list[CharacterConfig], output_path: str, 'speech_speed': getattr(config, 'speech_speed', 1.0), 'speech_volume': getattr(config, 'speech_volume', 1.0), 'pronunciation_map': getattr(config, 'pronunciation_map', None) or {}, + 'scenarios': getattr(config, 'scenarios', []) or [], } # 处理绝对路径的模型和参考音频 @@ -208,6 +209,15 @@ def export_character(character_configs: list[CharacterConfig], output_path: str, if isinstance(sprites, list): char_data['sprites'] = normalized_sprites + # 重写 scenario voice_path 为仅文件名 + scenarios = char_data.get('scenarios') or [] + if isinstance(scenarios, list): + for s in scenarios: + if isinstance(s, dict) and s.get('voice_path'): + s['voice_path'] = _safe_package_basename_or_legacy_absolute( + s['voice_path'], "scenario voice_path" + ) + # 复制语音文件 if config.sprite_prefix: voice_src_dir = SPEECH_DIR / config.sprite_prefix @@ -400,7 +410,24 @@ def import_character(input_path: str) -> list[CharacterConfig]: s['voice_path'] = new_vp.as_posix() else: s['voice_path'] = filename - + + # 修复 scenario voice_path:指向 SPEECH_DIR + scenarios = char_data.get('scenarios') or [] + if isinstance(scenarios, list): + for sc in scenarios: + if isinstance(sc, dict) and sc.get('voice_path'): + filename = _safe_package_basename_or_legacy_absolute( + sc['voice_path'], "scenario voice_path" + ) + if new_sprite_prefix: + new_vp = dest_speech_dir / filename + try: + sc['voice_path'] = str(new_vp.relative_to(Path.cwd())) + except ValueError: + sc['voice_path'] = new_vp.as_posix() + else: + sc['voice_path'] = filename + # 恢复模型文件并更新路径 model_paths = { 'gpt_model_path': char_data.get('gpt_model_path'),