# 开发规格 ## 目标 后续开发要围绕一个目标:做出低延迟、有角色存在感的多模态虚拟角色。 不要把 MVP 做成普通 chatbot,也不要把用户自定义角色创建器放在第一屏。 ## 推荐文件结构 ```text Virtual-characters/ app.py requirements.txt README.md PROJECT_DESIGN.md RESEARCH_NOTES.md DEVELOPMENT_GUIDE.md src/ character_registry.py persona_skills.py dialogue_engine.py stream_protocol.py stage_driver.py tts_engine.py vision_engine.py image_engine.py assets/ characters/ generated/ live2d/ ``` ## Gradio 页面结构 使用 `gr.Blocks`,不要只用一个 `ChatInterface` 包到底。 建议布局: ```text 左侧:角色选择、角色简介、模式开关 中间:角色舞台 gr.HTML 右侧:Chatbot、输入框、Audio 输出 底部或折叠区:事件流、当前情绪、skill、模型调试信息 ``` 推荐组件: - `gr.Radio` / `gr.Dropdown`:选择角色。 - `gr.Chatbot`:聊天历史。 - `gr.Textbox`:文字输入。 - `gr.Audio(streaming=True, autoplay=True)`:播放 TTS 音频块。 - `gr.Image(sources=["upload", "webcam"])`:上传图片或摄像头拍照。 - `gr.HTML`:角色舞台。 - `gr.JSON` / `gr.Code`:展示事件流和调试信息。 - `gr.State`:保存当前角色、短期记忆、当前情绪、视觉观察、事件历史。 ## 角色配置 每个内置角色是一套 `CharacterPackage`,不是单独一段 prompt。 ```json { "id": "star_knight", "display_name": "星萤", "inspiration": "Firefly-like sci-fi heroine, originalized", "profile": { "identity": "星港失事后幸存的装甲驾驶员", "core_traits": ["温柔", "克制", "隐藏痛苦", "战斗时冷静"], "relationship_to_user": "把用户当成临时通讯频道里的同伴", "boundaries": ["不声称自己是官方角色", "不复述商业 IP 的完整剧情"] }, "dialogue_style": { "tone": "轻声、真诚、偶尔停顿", "sentence_shape": "短句为主", "catchphrases": ["我还在。", "别担心,我会守住这里。"] }, "skills": ["daily_chat", "emotional_support", "lore_hint", "battle_focus"], "voice": { "tts_model": "kokoro_or_other", "voice_preset": "soft_young_female", "pace": "slow", "emotion_strength": 0.6 }, "visual": { "mode": "static_image_or_live2d", "image_prompt": "original anime sci-fi girl, silver hair, teal eyes, light armor", "expressions": ["idle", "smile", "worried", "thinking", "battle_focus"] } } ``` ## 流式设计原则 ### 不使用“完整 JSON 后处理”作为主链路 如果模型必须先完整生成: ```json { "reply_text": "...很长一段话...", "emotion": "...", "motion": "...", "voice": {} } ``` 那么页面必须等 JSON 结束才能知道角色该做什么。用户会看到角色呆住,TTS 也无法尽早开始。 主链路应改成 **事件流协议**:模型一边生成,后端一边解析,Gradio 一边 `yield` 更新 UI。 ### 推荐协议:SSE + JSON 事件 外部接口优先使用 SSE,也就是 `text/event-stream`。每个 SSE frame 的 `data:` 里放一个完整 JSON 事件。这样浏览器、Modal、FastAPI 和 OpenAI-compatible 流式接口都更容易衔接。 内部日志和测试文件可以保存成 NDJSON。也就是一行一个 JSON 事件。NDJSON 适合落盘和调试,但不要把“模型必须原生输出合法 NDJSON”作为唯一方案。 示例: ```jsonl {"type":"stage","expression":"thinking","motion":"look_down","duration_ms":600} {"type":"voice","style":"soft","speed":0.92,"pitch":1.04} {"type":"skill","name":"emotional_support"} {"type":"text_delta","text":"嗯,"} {"type":"stage","expression":"worried","motion":"gentle_blink"} {"type":"text_delta","text":"我在听。你今天好像比平时更累一点。"} {"type":"sentence_end"} {"type":"text_delta","text":"先别急着解释,坐一会儿也可以。"} {"type":"sentence_end"} {"type":"stage","expression":"soft_smile","motion":"idle"} {"type":"done"} ``` 优点: - `stage` 可以先到,角色先抬头、思考、眨眼。 - `voice` 可以先到,TTS 知道第一句该用什么语气。 - `text_delta` 可以流式更新聊天框。 - `sentence_end` 可以触发分句 TTS,用户不必等整段话结束。 - `done` 用于收尾和状态归档。 推荐实现: ```text LLM / planner / rules -> 后端统一转成 CharacterEvent -> Modal/FastAPI 用 SSE 输出 -> Gradio handler 消费事件并 yield 多个组件状态 -> 调试面板把事件流另存为 NDJSON ``` 这样即使底层模型只会普通 token streaming,后端也可以在句子边界、关键词、初始规划结果里补充 `stage`、`voice`、`skill` 事件。 ### 事件类型 必须支持: - `stage`:控制角色舞台。 - `voice`:控制 TTS 参数。 - `skill`:说明本轮使用的 persona skill。 - `text_delta`:追加回复文本。 - `sentence_end`:触发当前句子的 TTS。 - `vision_note`:可选,说明当前视觉输入的理解结果。 - `debug`:可选,只展示在调试面板。 - `done`:结束事件。 - `error`:模型输出损坏或工具失败。 事件字段: ```json { "type": "stage", "expression": "worried", "motion": "gentle_blink", "intensity": 0.7, "duration_ms": 800 } ``` ```json { "type": "voice", "style": "soft", "speed": 0.92, "pitch": 1.04, "energy": 0.45 } ``` ```json { "type": "text_delta", "text": "我在听。" } ``` ## Gradio 流式输出实现 Gradio 支持用 Python generator 做流式输出。事件处理函数不要 `return` 一次性结果,而是多次 `yield`。 输出组件建议: ```python outputs = [ chatbot, character_stage, audio_output, event_debug, state, ] ``` 伪代码: ```python def respond_stream(user_text, chat_history, character_id, state): ctx = build_context(user_text, chat_history, character_id, state) partial_text = "" sentence_buffer = "" current_voice = default_voice(character_id) stage_state = make_stage_state(character_id, expression="listening") yield update(chat_history, stage_state, None, {"type": "stage", "expression": "listening"}, state) for event in llm_event_stream(ctx): if event["type"] == "stage": stage_state = apply_stage_event(stage_state, event) yield update(chat_history, stage_state, None, event, state) elif event["type"] == "voice": current_voice = merge_voice(current_voice, event) yield update(chat_history, stage_state, None, event, state) elif event["type"] == "text_delta": partial_text += event["text"] sentence_buffer += event["text"] chat_history = set_assistant_partial(chat_history, partial_text) yield update(chat_history, stage_state, None, event, state) elif event["type"] == "sentence_end": audio_chunk = synthesize_sentence(sentence_buffer, current_voice) sentence_buffer = "" stage_state = apply_stage_event(stage_state, {"type": "stage", "motion": "talk"}) yield update(chat_history, stage_state, audio_chunk, event, state) elif event["type"] == "done": stage_state = apply_stage_event(stage_state, {"type": "stage", "expression": "idle"}) yield update(chat_history, stage_state, None, event, state) ``` 注意: - Gradio generator 需要启用 queue。 - `gr.Audio(streaming=True, autoplay=True)` 可以接收后端逐块 yield 的音频。 - 音频块最好长度稳定,并且大于 1 秒,避免播放不稳定。 - 摄像头和麦克风输入可以用 `.stream()`,但第一版建议限制 `time_limit` 和 `stream_every`,避免占满队列。 ## LLM 输出策略 ### 方案 A:单模型直接输出事件流 一个模型直接输出 JSON event lines,再由后端包装成 SSE。 优点: - 架构简单。 - 情绪、文字和动作天然同源。 缺点: - 小模型可能输出非法 JSON 行。 - 需要严格 prompt、解析器和修复策略。 只适合实验。不要把它作为唯一生产路径。 ### 方案 B:快规划器 + 文本流模型 先用一个很短的规划 prompt 生成首批控制事件: ```jsonl {"type":"stage","expression":"thinking","motion":"look_at_user"} {"type":"voice","style":"soft","speed":0.95} {"type":"skill","name":"emotional_support"} ``` 然后主模型流式输出文字,后端用规则或轻量分类器在分句处补充情绪事件。 优点: - 页面响应更快。 - 首屏角色很快有动作。 - 对 JSON 合法性要求更低。 缺点: - 情绪和文本可能不完全一致。 建议第一版采用 **B 的变体**:先快速输出一个初始 stage/voice/skill,再让主模型输出文本;后端在分句处补充情绪事件。这样响应最快,也最不容易被非法 JSON 卡住。 ### 方案 C:纯文本流 + 后处理情绪分类 模型只流文本。后端每完成一句话,用轻量分类器或规则推断情绪。 优点:最稳。 缺点:不够“模型自己输出情绪”,角色动作也会滞后。 只作为 fallback。 ## 解析和容错 模型事件流、Modal SSE、规则补充事件都必须经过 `stream_protocol.py`。 职责: - 解析 SSE `data:` payload 或内部 NDJSON。 - 丢弃或修复不合法事件。 - 对未知字段做忽略,不让 UI 崩。 - 对缺失字段补默认值。 - 限制事件频率,防止 stage 抖动。 - 把模型输出事件归一化为前端可消费状态。 容错策略: ```text 非法 JSON 行 -> 作为 debug 记录,不进入舞台 未知 type -> debug text_delta 缺 text -> 丢弃 stage 缺 expression/motion -> 使用当前状态 voice 参数越界 -> clamp 长时间没有 text_delta -> 显示 thinking 状态 模型结束但没有 done -> 后端补 done ``` ## 角色舞台协议 后端不直接控制 Live2D 细节。后端只输出抽象状态: ```json { "expression": "worried", "motion": "gentle_blink", "mouth": "talking", "gaze": "user", "intensity": 0.7 } ``` `stage_driver.py` 负责映射: - 静态头像/CSS:切图、晃动、嘴部光效。 - 2.5D HTML:表情层、CSS transform、嘴型动画。 - Live2D:expression、motion、parameter、hotkey。 这样以后替换表现层时,不需要改对话模型。 ## TTS 流式策略 不要等全文结束再 TTS。使用分句级 TTS: 1. LLM 输出 `voice` 事件。 2. LLM 流式输出 `text_delta`。 3. 后端遇到中文句号、问号、叹号、换行,或模型输出 `sentence_end`。 4. 后端把当前句子送入 TTS。 5. TTS 产出音频块后立即 yield 到 `gr.Audio(streaming=True, autoplay=True)`。 6. 角色舞台进入 `talk` motion,音频结束后回 idle 或下一句 motion。 第一版可以不做真正逐 token TTS。分句 TTS 已经能显著降低等待感。 ## 摄像头流式策略 第一版不做持续实时视频理解。推荐: - 上传图片:稳定必做。 - 摄像头拍照:可选。 - 低频 stream:实验功能。 如果做 `.stream()`: - `stream_every` 不要太小,建议先 1-3 秒。 - `time_limit` 必须设置。 - VLM 不要每帧都跑,可以抽样或节流。 - 视觉结果写入 `state.last_vision_note`,下一轮对话使用。 ## 生图策略 生图只在后台资产生成或用户明确触发时运行。 流程: ```text CharacterPackage.visual.image_prompt -> image_engine -> 角色头像/半身像/背景 -> assets/generated/ -> stage_driver 使用 ``` 用户自定义角色: ```text 上传参考图/描述 -> VLM 提取抽象视觉元素 -> LLM 原创化 visual prompt -> 生图 -> 保存为新 CharacterPackage ``` ## MVP 开发顺序 ### Day 1:角色库和事件流 - 建 `CharacterPackage`。 - 建 3 个内置角色。 - 实现 `llm_event_stream` 的 mock 版本。 - 实现 `stream_protocol.py`。 - Gradio 页面能流式显示文本和事件调试。 ### Day 2:角色舞台和 TTS - 实现 `CharacterStage(gr.HTML)`。 - stage 支持 listening、thinking、talking、happy、worried。 - 接分句 TTS。 - `sentence_end` 后能播放音频并让嘴部动。 ### Day 3:真实模型、视觉输入、生图 - 接真实 LLM 或 Inference Provider。 - 上传图片后用 VLM 生成 `vision_note`。 - 接生图生成默认头像或重绘头像。 ### Day 4:打磨和演示 - 调整内置角色差异。 - 优化流式延迟。 - 加载一个 Live2D 或 2.5D 可动表现层作为加分项。 - 准备 README、示例对话和演示脚本。 ## 必须避免 - 等完整 JSON 结束后才更新 UI。 - 每轮对话都跑生图。 - 直接克隆商业角色声音。 - 公开 Space 直接复刻商业角色图像、台词和官方设定。 - 把摄像头实时流作为第一版主链路。 - 让前端执行模型生成的任意 JavaScript。 ## 官方能力依据 - Gradio 支持 Python generator 流式输出,可以反复 `yield` 组件值。 - Gradio `Audio(streaming=True, autoplay=True)` 支持后端逐块 yield 音频。 - Gradio `Image` 和 `Audio` 支持 `.stream()` 输入事件,适合摄像头和麦克风低频流。 - Gradio `.stream()` 应设置 `time_limit` 和 `stream_every`,避免单用户长期占用队列。 - Modal `fastapi_endpoint` 支持 FastAPI `StreamingResponse`,可以用 `text/event-stream` 直接发 SSE。