| # 开发规格 |
|
|
| ## 目标 |
|
|
| 后续开发要围绕一个目标:做出低延迟、有角色存在感的多模态虚拟角色。 |
|
|
| 不要把 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。 |
|
|