virtual-characters / DEVELOPMENT_GUIDE.md
ShadowInk's picture
Deploy Virtual Characters for Build Small Hackathon
005e075 verified
|
Raw
History Blame Contribute Delete
13.5 kB
# 开发规格
## 目标
后续开发要围绕一个目标:做出低延迟、有角色存在感的多模态虚拟角色。
不要把 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。