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")}
+
+
} onClick={onAddScenario} variant="ghost">
+ {t("character.voiceTag.add")}
+
+
}
+ 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 (
+
+ );
+ })}
+
+
+ {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'),