Deploy Virtual Characters for Build Small Hackathon
Browse files- .gitattributes +10 -0
- .gitignore +19 -0
- BENCHMARK_RESULTS.md +406 -0
- CHARACTER_GENERATION_SPIKE.md +152 -0
- DEVELOPMENT_GUIDE.md +455 -0
- MODAL_DEPLOYMENT.md +478 -0
- PROJECT_DESIGN.md +67 -0
- README.md +152 -7
- RESEARCH_NOTES.md +135 -0
- app.py +1877 -0
- assets/characters/star/focus.png +3 -0
- assets/characters/star/focus_preview_check.png +3 -0
- assets/characters/star/focus_preview_check2.png +3 -0
- assets/characters/star/happy.png +3 -0
- assets/characters/star/idle.png +3 -0
- assets/characters/star/listening.png +3 -0
- assets/characters/star/smile.png +3 -0
- assets/characters/star/talk.png +3 -0
- assets/characters/star/thinking.png +3 -0
- assets/characters/star/worried.png +3 -0
- demo_modal_stack.py +121 -0
- modal_apps/README.md +208 -0
- requirements.txt +6 -0
- src/character_spike/__init__.py +2 -0
- src/character_spike/assets.py +447 -0
- src/character_spike/schema.py +142 -0
- src/character_spike/tavern_import.py +131 -0
- tests/test_character_spike.py +112 -0
- tests/test_character_workshop.py +242 -0
- tests/test_dialogue_engine.py +70 -0
- tests/test_model_status.py +36 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
assets/characters/star/focus.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
assets/characters/star/focus_preview_check.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
assets/characters/star/focus_preview_check2.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
assets/characters/star/happy.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
+
assets/characters/star/idle.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
+
assets/characters/star/listening.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
+
assets/characters/star/smile.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
+
assets/characters/star/talk.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
+
assets/characters/star/thinking.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
+
assets/characters/star/worried.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
.uv-cache/
|
| 3 |
+
.tmp/
|
| 4 |
+
assets/generated/
|
| 5 |
+
.ruff_cache/
|
| 6 |
+
.pytest_cache/
|
| 7 |
+
.gradio/
|
| 8 |
+
.hf-cache/
|
| 9 |
+
__pycache__/
|
| 10 |
+
**/__pycache__/
|
| 11 |
+
*.pyc
|
| 12 |
+
|
| 13 |
+
.env
|
| 14 |
+
.logs/
|
| 15 |
+
*.log
|
| 16 |
+
*.tmp
|
| 17 |
+
modal_tts_check.wav
|
| 18 |
+
modal_tts_*_check.wav
|
| 19 |
+
modal_image_check.png
|
BENCHMARK_RESULTS.md
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Modal 实测记录
|
| 2 |
+
|
| 3 |
+
## 2026-06-11:Gemma 4 12B IT / L40S
|
| 4 |
+
|
| 5 |
+
模型:`google/gemma-4-12B-it`
|
| 6 |
+
Modal workspace:`veronicaulises0`
|
| 7 |
+
Modal profile:`verno`
|
| 8 |
+
GPU:`NVIDIA L40S`
|
| 9 |
+
脚本:`modal_apps/modal_gemma_benchmark.py`
|
| 10 |
+
|
| 11 |
+
### 结论
|
| 12 |
+
|
| 13 |
+
`google/gemma-4-12B-it` 可以在 Modal 的 L40S 上启动并完成中文生成。显存峰值约 22.3 GB,L40S 足够承载当前 Transformers `dtype="auto"` / `device_map="auto"` 路线。
|
| 14 |
+
|
| 15 |
+
首次运行主要慢在镜像构建和权重缓存;缓存后,冷容器加载模型约 10 秒,短文本生成速度约 12 tokens/s。
|
| 16 |
+
|
| 17 |
+
### 运行 1:首次构建 + 首次加载
|
| 18 |
+
|
| 19 |
+
命令:
|
| 20 |
+
|
| 21 |
+
```powershell
|
| 22 |
+
$env:VC_BENCH_MODEL="google/gemma-4-12B-it"
|
| 23 |
+
$env:VC_BENCH_GPU="L40S"
|
| 24 |
+
modal run modal_apps/modal_gemma_benchmark.py --max-new-tokens 64
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
结果:
|
| 28 |
+
|
| 29 |
+
```json
|
| 30 |
+
{
|
| 31 |
+
"model_id": "google/gemma-4-12B-it",
|
| 32 |
+
"gpu": "L40S",
|
| 33 |
+
"processor_load_s": 5.235,
|
| 34 |
+
"model_load_s": 107.528,
|
| 35 |
+
"prompt_tokens": 60,
|
| 36 |
+
"output_tokens": 54,
|
| 37 |
+
"generation_s": 4.867,
|
| 38 |
+
"tokens_per_s": 11.095,
|
| 39 |
+
"remote_function_s": 117.744,
|
| 40 |
+
"client_wall_s": 149.096,
|
| 41 |
+
"cuda_peak_memory_gb": 22.327
|
| 42 |
+
}
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Modal run:
|
| 46 |
+
|
| 47 |
+
```text
|
| 48 |
+
https://modal.com/apps/veronicaulises0/main/ap-2117aPK02FBCs96MlEmOyA
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### 运行 2:镜像与权重缓存后
|
| 52 |
+
|
| 53 |
+
命令:
|
| 54 |
+
|
| 55 |
+
```powershell
|
| 56 |
+
$env:VC_BENCH_MODEL="google/gemma-4-12B-it"
|
| 57 |
+
$env:VC_BENCH_GPU="L40S"
|
| 58 |
+
modal run modal_apps/modal_gemma_benchmark.py --max-new-tokens 128 --prompt "请你作为一个温柔但有一点科幻感的虚拟角色,用中文回答:如果用户今天压力很大,你会怎么安慰他?"
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
结果:
|
| 62 |
+
|
| 63 |
+
```json
|
| 64 |
+
{
|
| 65 |
+
"model_id": "google/gemma-4-12B-it",
|
| 66 |
+
"gpu": "L40S",
|
| 67 |
+
"processor_load_s": 3.501,
|
| 68 |
+
"model_load_s": 10.303,
|
| 69 |
+
"prompt_tokens": 65,
|
| 70 |
+
"output_tokens": 100,
|
| 71 |
+
"generation_s": 7.841,
|
| 72 |
+
"tokens_per_s": 12.753,
|
| 73 |
+
"remote_function_s": 21.761,
|
| 74 |
+
"client_wall_s": 33.73,
|
| 75 |
+
"cuda_peak_memory_gb": 22.343
|
| 76 |
+
}
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Modal run:
|
| 80 |
+
|
| 81 |
+
```text
|
| 82 |
+
https://modal.com/apps/veronicaulises0/main/ap-qWkxotIpDObX3lLwmHjI9f
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
### 开发判断
|
| 86 |
+
|
| 87 |
+
- L40S 能跑 12B,适合作为项目的主力 Modal 路线。
|
| 88 |
+
- 生成速度足够做 demo,但每次冷容器仍有 20-35 秒端到端等待;正式 demo 时应保持一个 LLM 容器 warm。
|
| 89 |
+
- 当前 Transformers 路线能用,但如果要多人并发,需要后续评估 vLLM / batching / quantization。
|
| 90 |
+
- Gemma 4 在关闭 thinking 时仍可能输出空的 `thought` 通道标记,前端事件流需要过滤该前缀。
|
| 91 |
+
|
| 92 |
+
## 2026-06-11:TTS 初测
|
| 93 |
+
|
| 94 |
+
### Chatterbox Multilingual / A10G
|
| 95 |
+
|
| 96 |
+
状态:已跑通中文合成。
|
| 97 |
+
|
| 98 |
+
命令:
|
| 99 |
+
|
| 100 |
+
```powershell
|
| 101 |
+
$env:VC_TTS_BACKEND="chatterbox"
|
| 102 |
+
$env:VC_TTS_GPU="A10G"
|
| 103 |
+
$env:VC_TTS_LANGUAGE_ID="zh"
|
| 104 |
+
modal run modal_apps/modal_tts.py --text "你好,我在听。今天辛苦了,先慢慢呼吸一下。" --emotion concerned --output-path modal_tts_chatterbox_check.wav --repeats 2
|
| 105 |
+
```
|
| 106 |
+
|
| 107 |
+
结果:
|
| 108 |
+
|
| 109 |
+
```json
|
| 110 |
+
{
|
| 111 |
+
"run_1": {
|
| 112 |
+
"remote_s": 58.593,
|
| 113 |
+
"audio_duration_s": 4.04,
|
| 114 |
+
"real_time_factor": 14.503,
|
| 115 |
+
"was_loaded": false
|
| 116 |
+
},
|
| 117 |
+
"run_2": {
|
| 118 |
+
"remote_s": 6.197,
|
| 119 |
+
"audio_duration_s": 10.36,
|
| 120 |
+
"real_time_factor": 0.598,
|
| 121 |
+
"was_loaded": true
|
| 122 |
+
},
|
| 123 |
+
"client_s": 67.836
|
| 124 |
+
}
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
判断:
|
| 128 |
+
|
| 129 |
+
- 冷启动重,首次加载模型和中文分词资源约 1 分钟。
|
| 130 |
+
- 热容器可用,第二次合成比实时快,RTF 约 0.60。
|
| 131 |
+
- 适合作为当前中文角色 TTS 主方案候选。
|
| 132 |
+
|
| 133 |
+
### Kokoro-82M / A10G
|
| 134 |
+
|
| 135 |
+
状态:未完成有效中文合成测速。
|
| 136 |
+
|
| 137 |
+
模型页显示 Kokoro v1.0 是多语言模型,发布表写到 8 种语言和 54 个声音;但 Hugging Face 顶部任务标签仍偏 English,中文路径依赖 `misaki` 的中文 G2P。实测中中文初始化连续缺少间接依赖:
|
| 138 |
+
|
| 139 |
+
- 第一次缺 `ordered_set`
|
| 140 |
+
- 补上后缺 `pypinyin`
|
| 141 |
+
|
| 142 |
+
判断:
|
| 143 |
+
|
| 144 |
+
- Kokoro 不是纯英文模型,但中文链路不够稳。
|
| 145 |
+
- 不建议作为本项目主 TTS。
|
| 146 |
+
- 可以保留为低成本 fallback,后续只在依赖补齐后再测。
|
| 147 |
+
|
| 148 |
+
## 2026-06-11:vLLM / Gemma 4 12B IT
|
| 149 |
+
|
| 150 |
+
状态:未跑通。
|
| 151 |
+
|
| 152 |
+
命令:
|
| 153 |
+
|
| 154 |
+
```powershell
|
| 155 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 156 |
+
$env:VC_VLLM_GPU="L40S"
|
| 157 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 158 |
+
modal run modal_apps/modal_vllm_gemma.py --max-tokens 128
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
结果:
|
| 162 |
+
|
| 163 |
+
- Modal 镜像构建成功。
|
| 164 |
+
- vLLM 0.21.0 能识别模型,但日志提示 `TransformersMultiModalForCausalLM has no vLLM implementation, falling back to Transformers implementation`。
|
| 165 |
+
- 随后 engine profile run 失败,错误为矩阵 shape mismatch:
|
| 166 |
+
|
| 167 |
+
```text
|
| 168 |
+
RuntimeError: mat1 and mat2 shapes cannot be multiplied (2048x4096 and 8192x3840)
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
判断:
|
| 172 |
+
|
| 173 |
+
- 当前 vLLM 0.21.0 不能直接稳定承载 `google/gemma-4-12B-it`。
|
| 174 |
+
- 这个失败不是 Modal 登录、HF token 或显存问题;模型权重已加载到约 22.56 GiB 后,在 vLLM fallback 执行路径失败。
|
| 175 |
+
- 项目当前主 LLM 路线继续使用 Transformers Modal 服务。
|
| 176 |
+
- 如果要追 vLLM 速度,需要等 vLLM 原生支持 Gemma 4 Unified,或改测另一个 vLLM 原生支���的文本模型。
|
| 177 |
+
|
| 178 |
+
Modal run:
|
| 179 |
+
|
| 180 |
+
```text
|
| 181 |
+
https://modal.com/apps/veronicaulises0/main/ap-oSGKVGLu8Jih0nLoPQH3Ub
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
## 2026-06-12:vLLM 0.22.1 / Gemma 4 12B IT
|
| 185 |
+
|
| 186 |
+
状态:稳定版仍未跑通。
|
| 187 |
+
|
| 188 |
+
命令:
|
| 189 |
+
|
| 190 |
+
```powershell
|
| 191 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 192 |
+
$env:VC_VLLM_VERSION="0.22.1"
|
| 193 |
+
$env:VC_VLLM_GPU="L40S"
|
| 194 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 195 |
+
.venv\Scripts\modal.exe run modal_apps/modal_vllm_gemma.py --max-tokens 128
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
结果:
|
| 199 |
+
|
| 200 |
+
- PyPI 最新稳定版是 `vllm==0.22.1`,发布日期为 2026-06-05。
|
| 201 |
+
- vLLM 0.22.1 仍把该模型解析为 `TransformersMultiModalForCausalLM`。
|
| 202 |
+
- 日志仍提示没有 vLLM 原生实现,并 fallback 到 Transformers。
|
| 203 |
+
- 随后仍在 engine profile run 阶段失败:
|
| 204 |
+
|
| 205 |
+
```text
|
| 206 |
+
RuntimeError: mat1 and mat2 shapes cannot be multiplied (2048x4096 and 8192x3840)
|
| 207 |
+
```
|
| 208 |
+
|
| 209 |
+
判断:
|
| 210 |
+
|
| 211 |
+
- `vllm==0.22.1` 还不能作为 `google/gemma-4-12B-it` 的可用部署版本。
|
| 212 |
+
- 需要追 vLLM main/nightly,而不是继续测 PyPI 稳定版。
|
| 213 |
+
- 后续已用更新的 nightly `0.22.1rc1.dev468+gfbc3a1907.cu129` 跑通,见下一节。
|
| 214 |
+
|
| 215 |
+
Modal run:
|
| 216 |
+
|
| 217 |
+
```text
|
| 218 |
+
https://modal.com/apps/veronicaulises0/main/ap-G9ApqBk26I7ftDfnrHNFwh
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
## 2026-06-12:vLLM nightly / Gemma 4 12B IT 部署成功
|
| 222 |
+
|
| 223 |
+
状态:已在 Modal 上跑通并部署。
|
| 224 |
+
|
| 225 |
+
模型:`google/gemma-4-12B-it`
|
| 226 |
+
Modal workspace:`veronicaulises0`
|
| 227 |
+
Modal profile:`verno`
|
| 228 |
+
GPU:`NVIDIA L40S`
|
| 229 |
+
vLLM:`0.22.1rc1.dev468+gfbc3a1907.cu129`
|
| 230 |
+
部署 URL:`https://veronicaulises0--virtual-characters-vllm-gemma-serve.modal.run`
|
| 231 |
+
|
| 232 |
+
### 关键结论
|
| 233 |
+
|
| 234 |
+
- vLLM nightly 能把模型解析为 `Gemma4UnifiedForConditionalGeneration`,不再 fallback 到 `TransformersMultiModalForCausalLM`。
|
| 235 |
+
- L40S 上权重加载约 22.83 GiB,KV cache 可用约 15.97 GiB。
|
| 236 |
+
- `max_model_len=8192` 时日志显示 GPU KV cache size 约 108,675 tokens,理论最大并发约 13.27x。
|
| 237 |
+
- 当前部署使用 `VC_SKIP_HF_SECRET=1`,没有把 `hf-token` 挂到 nightly 运行环境;能启动是因为 Modal Volume `vc-hf-cache` 中已有模型权重缓存。
|
| 238 |
+
|
| 239 |
+
### 成功运行命令
|
| 240 |
+
|
| 241 |
+
```powershell
|
| 242 |
+
$env:PYTHONIOENCODING="utf-8"
|
| 243 |
+
$env:PYTHONUTF8="1"
|
| 244 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 245 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 246 |
+
$env:VC_VLLM_PACKAGE="vllm==0.22.1rc1.dev468+gfbc3a1907.cu129"
|
| 247 |
+
$env:VC_VLLM_EXTRA_INDEX_URL="https://wheels.vllm.ai/nightly/cu129"
|
| 248 |
+
$env:VC_VLLM_UV_EXTRA_OPTIONS="--index-strategy unsafe-best-match"
|
| 249 |
+
$env:VC_VLLM_PRE="1"
|
| 250 |
+
$env:VC_VLLM_GPU="L40S"
|
| 251 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 252 |
+
.venv\Scripts\modal.exe run modal_apps/modal_vllm_gemma.py --max-tokens 16 --test-timeout 900
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
结果:
|
| 256 |
+
|
| 257 |
+
```json
|
| 258 |
+
{
|
| 259 |
+
"model_id": "google/gemma-4-12B-it",
|
| 260 |
+
"vllm_package": "vllm==0.22.1rc1.dev468+gfbc3a1907.cu129",
|
| 261 |
+
"gpu": "L40S",
|
| 262 |
+
"fast_boot": true,
|
| 263 |
+
"client_total_s": 197.746,
|
| 264 |
+
"ttft_s": 5.119,
|
| 265 |
+
"stream_total_s": 5.68,
|
| 266 |
+
"completion_tokens": 16,
|
| 267 |
+
"stream_tokens_per_s": 2.817,
|
| 268 |
+
"response_preview": "辛苦了,抱抱你。我知道今天一定很不容易,但现在你",
|
| 269 |
+
"non_stream_s": 1.01,
|
| 270 |
+
"non_stream_completion_tokens": 16,
|
| 271 |
+
"non_stream_tokens_per_s": 15.846
|
| 272 |
+
}
|
| 273 |
+
```
|
| 274 |
+
|
| 275 |
+
Modal run:
|
| 276 |
+
|
| 277 |
+
```text
|
| 278 |
+
https://modal.com/apps/veronicaulises0/main/ap-2yEEKYd6mH1OkpPNNEPnjB
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
### 持久部署
|
| 282 |
+
|
| 283 |
+
命令:
|
| 284 |
+
|
| 285 |
+
```powershell
|
| 286 |
+
$env:PYTHONIOENCODING="utf-8"
|
| 287 |
+
$env:PYTHONUTF8="1"
|
| 288 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 289 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 290 |
+
$env:VC_VLLM_PACKAGE="vllm==0.22.1rc1.dev468+gfbc3a1907.cu129"
|
| 291 |
+
$env:VC_VLLM_EXTRA_INDEX_URL="https://wheels.vllm.ai/nightly/cu129"
|
| 292 |
+
$env:VC_VLLM_UV_EXTRA_OPTIONS="--index-strategy unsafe-best-match"
|
| 293 |
+
$env:VC_VLLM_PRE="1"
|
| 294 |
+
$env:VC_VLLM_GPU="L40S"
|
| 295 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 296 |
+
.venv\Scripts\modal.exe deploy modal_apps/modal_vllm_gemma.py
|
| 297 |
+
```
|
| 298 |
+
|
| 299 |
+
结果:
|
| 300 |
+
|
| 301 |
+
```text
|
| 302 |
+
https://veronicaulises0--virtual-characters-vllm-gemma-serve.modal.run
|
| 303 |
+
```
|
| 304 |
+
|
| 305 |
+
### 正式 endpoint warm 测试
|
| 306 |
+
|
| 307 |
+
请求:`POST /v1/chat/completions`
|
| 308 |
+
|
| 309 |
+
结果:
|
| 310 |
+
|
| 311 |
+
```json
|
| 312 |
+
{
|
| 313 |
+
"chat_elapsed_s": 2.607,
|
| 314 |
+
"content": "别给自己太大压力,你已经做得很棒了。\n累了就先停下来歇一会,我会一直陪在你身边的。",
|
| 315 |
+
"usage": {
|
| 316 |
+
"prompt_tokens": 48,
|
| 317 |
+
"completion_tokens": 31,
|
| 318 |
+
"total_tokens": 79
|
| 319 |
+
},
|
| 320 |
+
"system_fingerprint": "vllm-0.22.1rc1.dev468+gfbc3a1907-3443f622"
|
| 321 |
+
}
|
| 322 |
+
```
|
| 323 |
+
|
| 324 |
+
注意:
|
| 325 |
+
|
| 326 |
+
- 正式 endpoint 冷启动需要约 3 分钟,外部 `/health` 请求可能在冷启动窗口超时。
|
| 327 |
+
- 第一次生成会触发 Triton JIT,TTFT 会偏高;warm 后短回复约 10-16 tok/s。
|
| 328 |
+
- 如果清空 Modal Volume 或换新 workspace,当前无 secret 部署会因为无法下载权重而失败。要让它在空缓存环境可复现,需要明确允许 nightly vLLM 环境挂载 `hf-token`,或者先用受信任的独立流程把权重预缓存进 Volume。
|
| 329 |
+
|
| 330 |
+
## 2026-06-11:TTS 分句流式
|
| 331 |
+
|
| 332 |
+
状态:可行,但不是底层音频流式。
|
| 333 |
+
|
| 334 |
+
命令:
|
| 335 |
+
|
| 336 |
+
```powershell
|
| 337 |
+
$env:VC_TTS_BACKEND="chatterbox"
|
| 338 |
+
$env:VC_TTS_GPU="A10G"
|
| 339 |
+
$env:VC_TTS_LANGUAGE_ID="zh"
|
| 340 |
+
modal run modal_apps/modal_tts.py --text "我在听。今天辛苦了。先慢慢呼吸一下,���吗?" --emotion concerned --output-path modal_tts_sentence_stream_check.wav --sentence-stream
|
| 341 |
+
```
|
| 342 |
+
|
| 343 |
+
结果:
|
| 344 |
+
|
| 345 |
+
```json
|
| 346 |
+
{
|
| 347 |
+
"backend": "chatterbox",
|
| 348 |
+
"gpu": "A10G",
|
| 349 |
+
"sentences": 3,
|
| 350 |
+
"first_audio_s": 58.594,
|
| 351 |
+
"total_s": 63.31,
|
| 352 |
+
"events": [
|
| 353 |
+
{
|
| 354 |
+
"text": "我在听。",
|
| 355 |
+
"chunk_s": 58.593,
|
| 356 |
+
"audio_duration_s": 1.4,
|
| 357 |
+
"real_time_factor": 41.852
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
"text": "今天辛苦了。",
|
| 361 |
+
"chunk_s": 2.138,
|
| 362 |
+
"audio_duration_s": 3.0,
|
| 363 |
+
"real_time_factor": 0.713
|
| 364 |
+
},
|
| 365 |
+
{
|
| 366 |
+
"text": "先慢慢呼吸一下,好吗?",
|
| 367 |
+
"chunk_s": 2.578,
|
| 368 |
+
"audio_duration_s": 3.84,
|
| 369 |
+
"real_time_factor": 0.671
|
| 370 |
+
}
|
| 371 |
+
]
|
| 372 |
+
}
|
| 373 |
+
```
|
| 374 |
+
|
| 375 |
+
判断:
|
| 376 |
+
|
| 377 |
+
- Chatterbox 当前 API 是整句生成,不是边采样边输出 PCM 的底层流式。
|
| 378 |
+
- 可实现句子级流式:LLM 每完成一句,TTS 合成一句并立刻发给 Gradio。
|
| 379 |
+
- 冷启动首句很慢;容器 warm 后,后续句子 2-3 秒一段,能支撑 demo。
|
| 380 |
+
|
| 381 |
+
## 2026-06-11:简单 Modal Demo
|
| 382 |
+
|
| 383 |
+
新增文件:
|
| 384 |
+
|
| 385 |
+
```text
|
| 386 |
+
demo_modal_stack.py
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
用途:
|
| 390 |
+
|
| 391 |
+
- Gradio 最小端到端烟测。
|
| 392 |
+
- 直接调用 Modal class methods,不需要先 deploy endpoint。
|
| 393 |
+
- LLM 使用当前已跑通的 Transformers `PersonaLLM.generate_text`。
|
| 394 |
+
- TTS 使用 Chatterbox `CharacterTTS.synthesize`,按句生成并逐段更新音频。
|
| 395 |
+
|
| 396 |
+
运行:
|
| 397 |
+
|
| 398 |
+
```powershell
|
| 399 |
+
python demo_modal_stack.py
|
| 400 |
+
```
|
| 401 |
+
|
| 402 |
+
默认端口:
|
| 403 |
+
|
| 404 |
+
```text
|
| 405 |
+
127.0.0.1:7862
|
| 406 |
+
```
|
CHARACTER_GENERATION_SPIKE.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 自动化角色生成风险验证
|
| 2 |
+
|
| 3 |
+
这不是完整角色工坊实现,而是一个可复现的 Modal 技术 spike。目标是先验证“文生图、图生图、身份保持、姿态控制、表情生成”是否能稳定支撑多表情角色包,再决定是否进入完整 UI。
|
| 4 |
+
|
| 5 |
+
## 入口
|
| 6 |
+
|
| 7 |
+
```powershell
|
| 8 |
+
python scripts/run_character_generation_spike.py list-models
|
| 9 |
+
python scripts/run_character_generation_spike.py mock-assets --character-id spike_star --display-name 星核
|
| 10 |
+
python scripts/run_character_generation_spike.py import-tavern --input path\to\card.json --output-dir characters
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
Modal health check 不加载模型权重:
|
| 14 |
+
|
| 15 |
+
```powershell
|
| 16 |
+
python scripts/run_character_generation_spike.py modal-health
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
远程生图会消耗 GPU,必须显式确认:
|
| 20 |
+
|
| 21 |
+
```powershell
|
| 22 |
+
python scripts/run_character_generation_spike.py modal-probe --candidate flux_schnell --batch-size 1 --confirm-gpu
|
| 23 |
+
python scripts/run_character_generation_spike.py modal-benchmark --candidates flux_schnell qwen_image --confirm-gpu
|
| 24 |
+
python scripts/run_character_generation_spike.py modal-benchmark --candidates qwen_image_edit --init-image path\to\reference.png --include-expressions --confirm-gpu
|
| 25 |
+
python scripts/run_character_generation_spike.py modal-benchmark --candidates qwen_controlnet_union --control-image path\to\pose.png --include-expressions --confirm-gpu
|
| 26 |
+
```
|
| 27 |
+
|
| 28 |
+
## 模型候选
|
| 29 |
+
|
| 30 |
+
| ID | 目标 | 模型 | 默认步数 | 状态 |
|
| 31 |
+
| --- | --- | --- | ---: | --- |
|
| 32 |
+
| `flux_schnell` | 速度基线、主视觉候选 | `black-forest-labs/FLUX.1-schnell` | 4 | 已接入 |
|
| 33 |
+
| `qwen_image` | 中文 prompt、文生图质量 | `Qwen/Qwen-Image` | 50 | 已接入 |
|
| 34 |
+
| `qwen_image_edit` | 图生图、表情编辑、局部编辑 | `Qwen/Qwen-Image-Edit` | 50 | 已接入 |
|
| 35 |
+
| `qwen_controlnet_union` | pose/canny/depth 控制动作 | `InstantX/Qwen-Image-ControlNet-Union` | 30 | 已接入 |
|
| 36 |
+
| `instantid_sdxl` | 身份一致性候选 | `InstantX/InstantID` | 30 | 暂不启用 |
|
| 37 |
+
|
| 38 |
+
`InstantID` 暂时只列入候选,不在 Modal runner 中启用。原因是它依赖 face-analysis 组件和额外模型下载路径;在没有锁定 antelopev2/insightface 部署方式前,先避免把高风险依赖混进第一轮 benchmark。
|
| 39 |
+
|
| 40 |
+
## 产物结构
|
| 41 |
+
|
| 42 |
+
mock 资产包:
|
| 43 |
+
|
| 44 |
+
```text
|
| 45 |
+
assets/generated/character_spike/<character_id>/
|
| 46 |
+
assets/characters/<character_id>/
|
| 47 |
+
idle.png
|
| 48 |
+
listening.png
|
| 49 |
+
thinking.png
|
| 50 |
+
worried.png
|
| 51 |
+
smile.png
|
| 52 |
+
happy.png
|
| 53 |
+
talk.png
|
| 54 |
+
focus.png
|
| 55 |
+
assets/backgrounds/<character_id>_spike_background.png
|
| 56 |
+
characters/<character_id>.json
|
| 57 |
+
generated/
|
| 58 |
+
asset_grid.png
|
| 59 |
+
manifest.json
|
| 60 |
+
report.md
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
Modal benchmark 会额外创建一层 run 目录:
|
| 64 |
+
|
| 65 |
+
```text
|
| 66 |
+
assets/generated/character_spike/<character_id>/<run_name>/generated/
|
| 67 |
+
manifest.json
|
| 68 |
+
report.md
|
| 69 |
+
<candidate>/<benchmark_case>/00.png
|
| 70 |
+
<candidate>/<benchmark_case>/grid.png
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
## Manifest 记录
|
| 74 |
+
|
| 75 |
+
`manifest.json` 记录:
|
| 76 |
+
|
| 77 |
+
- prompt、seed、模型、步数、GPU、宽高。
|
| 78 |
+
- Modal 远端耗时和本地调用 wall time。
|
| 79 |
+
- cold/warm 状态:`loaded_before=false` 表示该候选首次加载权重。
|
| 80 |
+
- 输出图片路径、字节数、失败原因。
|
| 81 |
+
- 人工评分占位:`manual_score`。
|
| 82 |
+
- gate 结果:是否满足进入角色工坊的最低门槛。
|
| 83 |
+
|
| 84 |
+
## 准入门槛
|
| 85 |
+
|
| 86 |
+
进入完整角色工坊前必须满足:
|
| 87 |
+
|
| 88 |
+
- warm 4 张主视觉候选耗时小于 60 秒,或明确标记为慢速模式。
|
| 89 |
+
- 8 张表情/动作总耗时小于 180 秒,或产品上接受后台异步等待。
|
| 90 |
+
- 至少 6/8 张资产肉眼可用。
|
| 91 |
+
- 身份一致性不明显崩坏。
|
| 92 |
+
- 透明抠图在当前舞台里可用。
|
| 93 |
+
|
| 94 |
+
不达标时,目标降级为:
|
| 95 |
+
|
| 96 |
+
```text
|
| 97 |
+
导入角色卡 + 单张立绘 + 背景 + 对话
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
多表情先用 prompt/LLM stage 事件驱动现有静态图,不强推模型批量生成。
|
| 101 |
+
|
| 102 |
+
## Tavern JSON 导入
|
| 103 |
+
|
| 104 |
+
支持先导入 JSON 角色卡,不直接污染内置 registry:
|
| 105 |
+
|
| 106 |
+
```powershell
|
| 107 |
+
python scripts/run_character_generation_spike.py import-tavern --input path\to\card.json --output-dir characters
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
字段映射:
|
| 111 |
+
|
| 112 |
+
- `name` -> `display_name`
|
| 113 |
+
- `description/personality/scenario` -> `profile` 和 `summary`
|
| 114 |
+
- `first_mes` -> 首句样例
|
| 115 |
+
- `alternate_greetings` -> 备用开场
|
| 116 |
+
- `creator_notes/tags` -> metadata 和 tags
|
| 117 |
+
- `character_book/world_info/lorebook` -> metadata.character_book
|
| 118 |
+
|
| 119 |
+
PNG metadata 角色卡暂不解析。若用户给 PNG,先当普通参考图处理,再在第二阶段增加 metadata 解包。
|
| 120 |
+
|
| 121 |
+
## Stage smoke
|
| 122 |
+
|
| 123 |
+
生成 mock 包后可以临时走现有 `stage_driver` 渲染,不复制资产到主 app:
|
| 124 |
+
|
| 125 |
+
```powershell
|
| 126 |
+
python scripts/run_character_generation_spike.py stage-smoke --run-dir assets\generated\character_spike\spike_star --character-id spike_star
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
也可以把 Modal benchmark 的 8 表情 probe 输出打成舞台资产包:
|
| 130 |
+
|
| 131 |
+
```powershell
|
| 132 |
+
python scripts/run_character_generation_spike.py package-probe-assets --source-run-dir assets\generated\character_spike\spike_eval\qwen_image_expressions_20260614 --candidate qwen_image --character-id qwen_spike_star --display-name 星核
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
如果浅色头发/皮肤被简单抠图误伤,先保留背景作为降级包:
|
| 136 |
+
|
| 137 |
+
```powershell
|
| 138 |
+
python scripts/run_character_generation_spike.py package-probe-assets --source-run-dir assets\generated\character_spike\spike_eval\qwen_image_expressions_20260614 --candidate qwen_image --character-id qwen_spike_star_bg --display-name 星核 --keep-background
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
输出:
|
| 142 |
+
|
| 143 |
+
```text
|
| 144 |
+
generated/stage_smoke.html
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
如果人工确认某个包可用,再显式安装到 app 路径:
|
| 148 |
+
|
| 149 |
+
```powershell
|
| 150 |
+
python scripts/run_character_generation_spike.py install-package --run-dir assets\generated\character_spike\spike_star --dry-run
|
| 151 |
+
python scripts/run_character_generation_spike.py install-package --run-dir assets\generated\character_spike\spike_star
|
| 152 |
+
```
|
DEVELOPMENT_GUIDE.md
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 开发规格
|
| 2 |
+
|
| 3 |
+
## 目标
|
| 4 |
+
|
| 5 |
+
后续开发要围绕一个目标:做出低延迟、有角色存在感的多模态虚拟角色。
|
| 6 |
+
|
| 7 |
+
不要把 MVP 做成普通 chatbot,也不要把用户自定义角色创建器放在第一屏。
|
| 8 |
+
|
| 9 |
+
## 推荐文件结构
|
| 10 |
+
|
| 11 |
+
```text
|
| 12 |
+
Virtual-characters/
|
| 13 |
+
app.py
|
| 14 |
+
requirements.txt
|
| 15 |
+
README.md
|
| 16 |
+
PROJECT_DESIGN.md
|
| 17 |
+
RESEARCH_NOTES.md
|
| 18 |
+
DEVELOPMENT_GUIDE.md
|
| 19 |
+
src/
|
| 20 |
+
character_registry.py
|
| 21 |
+
persona_skills.py
|
| 22 |
+
dialogue_engine.py
|
| 23 |
+
stream_protocol.py
|
| 24 |
+
stage_driver.py
|
| 25 |
+
tts_engine.py
|
| 26 |
+
vision_engine.py
|
| 27 |
+
image_engine.py
|
| 28 |
+
assets/
|
| 29 |
+
characters/
|
| 30 |
+
generated/
|
| 31 |
+
live2d/
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Gradio 页面结构
|
| 35 |
+
|
| 36 |
+
使用 `gr.Blocks`,不要只用一个 `ChatInterface` 包到底。
|
| 37 |
+
|
| 38 |
+
建议布局:
|
| 39 |
+
|
| 40 |
+
```text
|
| 41 |
+
左侧:角色选择、角色简介、模式开关
|
| 42 |
+
中间:角色舞台 gr.HTML
|
| 43 |
+
右侧:Chatbot、输入框、Audio 输出
|
| 44 |
+
底部或折叠区:事件流、当前情绪、skill、模型调试信息
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
推荐组件:
|
| 48 |
+
|
| 49 |
+
- `gr.Radio` / `gr.Dropdown`:选择角色。
|
| 50 |
+
- `gr.Chatbot`:聊天历史。
|
| 51 |
+
- `gr.Textbox`:文字输入。
|
| 52 |
+
- `gr.Audio(streaming=True, autoplay=True)`:播放 TTS 音频块。
|
| 53 |
+
- `gr.Image(sources=["upload", "webcam"])`:上传图片或摄像头拍照。
|
| 54 |
+
- `gr.HTML`:角色舞台。
|
| 55 |
+
- `gr.JSON` / `gr.Code`:展示事件流和调试信息。
|
| 56 |
+
- `gr.State`:保存当前角色、短期记忆、当前情绪、视觉观察、事件历史。
|
| 57 |
+
|
| 58 |
+
## 角色配置
|
| 59 |
+
|
| 60 |
+
每个内置角色是一套 `CharacterPackage`,不是单独一段 prompt。
|
| 61 |
+
|
| 62 |
+
```json
|
| 63 |
+
{
|
| 64 |
+
"id": "star_knight",
|
| 65 |
+
"display_name": "星萤",
|
| 66 |
+
"inspiration": "Firefly-like sci-fi heroine, originalized",
|
| 67 |
+
"profile": {
|
| 68 |
+
"identity": "星港失事后幸存的装甲驾驶员",
|
| 69 |
+
"core_traits": ["温柔", "克制", "隐藏痛苦", "战斗时冷静"],
|
| 70 |
+
"relationship_to_user": "把用户当成临时通讯频道里的同伴",
|
| 71 |
+
"boundaries": ["不声称自己是官方角色", "不复述商业 IP 的完整剧情"]
|
| 72 |
+
},
|
| 73 |
+
"dialogue_style": {
|
| 74 |
+
"tone": "轻声、真诚、偶尔停顿",
|
| 75 |
+
"sentence_shape": "短句为主",
|
| 76 |
+
"catchphrases": ["我还在。", "别担心,我会守住这里。"]
|
| 77 |
+
},
|
| 78 |
+
"skills": ["daily_chat", "emotional_support", "lore_hint", "battle_focus"],
|
| 79 |
+
"voice": {
|
| 80 |
+
"tts_model": "kokoro_or_other",
|
| 81 |
+
"voice_preset": "soft_young_female",
|
| 82 |
+
"pace": "slow",
|
| 83 |
+
"emotion_strength": 0.6
|
| 84 |
+
},
|
| 85 |
+
"visual": {
|
| 86 |
+
"mode": "static_image_or_live2d",
|
| 87 |
+
"image_prompt": "original anime sci-fi girl, silver hair, teal eyes, light armor",
|
| 88 |
+
"expressions": ["idle", "smile", "worried", "thinking", "battle_focus"]
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
## 流式设计原则
|
| 94 |
+
|
| 95 |
+
### 不使用“完整 JSON 后处理”作为主链路
|
| 96 |
+
|
| 97 |
+
如果模型必须先完整生成:
|
| 98 |
+
|
| 99 |
+
```json
|
| 100 |
+
{
|
| 101 |
+
"reply_text": "...很长一段话...",
|
| 102 |
+
"emotion": "...",
|
| 103 |
+
"motion": "...",
|
| 104 |
+
"voice": {}
|
| 105 |
+
}
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
那么页面必须等 JSON 结束才能知道角色该做什么。用户会看到角色呆住,TTS 也无法尽早开始。
|
| 109 |
+
|
| 110 |
+
主链路应改成 **事件流协议**:模型一边生成,后端一边解析,Gradio 一边 `yield` 更新 UI。
|
| 111 |
+
|
| 112 |
+
### 推荐协议:SSE + JSON 事件
|
| 113 |
+
|
| 114 |
+
外部接口优先使用 SSE,也就是 `text/event-stream`。每个 SSE frame 的 `data:` 里放一个完整 JSON 事件。这样浏览器、Modal、FastAPI 和 OpenAI-compatible 流式接口都更容易衔接。
|
| 115 |
+
|
| 116 |
+
内部日志和测试文件可以保存成 NDJSON。也就是一行一个 JSON 事件。NDJSON 适合落盘和调试,但不要把“模型必须原生输出合法 NDJSON”作为唯一方案。
|
| 117 |
+
|
| 118 |
+
示例:
|
| 119 |
+
|
| 120 |
+
```jsonl
|
| 121 |
+
{"type":"stage","expression":"thinking","motion":"look_down","duration_ms":600}
|
| 122 |
+
{"type":"voice","style":"soft","speed":0.92,"pitch":1.04}
|
| 123 |
+
{"type":"skill","name":"emotional_support"}
|
| 124 |
+
{"type":"text_delta","text":"嗯,"}
|
| 125 |
+
{"type":"stage","expression":"worried","motion":"gentle_blink"}
|
| 126 |
+
{"type":"text_delta","text":"我在听。你今天好像比平时更累一点。"}
|
| 127 |
+
{"type":"sentence_end"}
|
| 128 |
+
{"type":"text_delta","text":"先别急着解释,坐一会儿也可以。"}
|
| 129 |
+
{"type":"sentence_end"}
|
| 130 |
+
{"type":"stage","expression":"soft_smile","motion":"idle"}
|
| 131 |
+
{"type":"done"}
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
优点:
|
| 135 |
+
|
| 136 |
+
- `stage` 可以先到,角色先抬头、思考、眨眼。
|
| 137 |
+
- `voice` 可以先到,TTS 知道第一句该用什么语气。
|
| 138 |
+
- `text_delta` 可以流式更新聊天框。
|
| 139 |
+
- `sentence_end` 可以触发分句 TTS,用户不必等整段话结束。
|
| 140 |
+
- `done` 用于收尾和状态归档。
|
| 141 |
+
|
| 142 |
+
推荐实现:
|
| 143 |
+
|
| 144 |
+
```text
|
| 145 |
+
LLM / planner / rules
|
| 146 |
+
-> 后端统一转成 CharacterEvent
|
| 147 |
+
-> Modal/FastAPI 用 SSE 输出
|
| 148 |
+
-> Gradio handler 消费事件并 yield 多个组件状态
|
| 149 |
+
-> 调试面板把事件流另存为 NDJSON
|
| 150 |
+
```
|
| 151 |
+
|
| 152 |
+
这样即使底层模型只会普通 token streaming,后端也可以在句子边界、关键词、初始规划结果里补充 `stage`、`voice`、`skill` 事件。
|
| 153 |
+
|
| 154 |
+
### 事件类型
|
| 155 |
+
|
| 156 |
+
必须支持:
|
| 157 |
+
|
| 158 |
+
- `stage`:控制角色舞台。
|
| 159 |
+
- `voice`:控制 TTS 参数。
|
| 160 |
+
- `skill`:说明本轮使用的 persona skill。
|
| 161 |
+
- `text_delta`:追加回复文本。
|
| 162 |
+
- `sentence_end`:触发当前句子的 TTS。
|
| 163 |
+
- `vision_note`:可选,说明当前视觉输入的理解结果。
|
| 164 |
+
- `debug`:可选,只展示在调试面板。
|
| 165 |
+
- `done`:结束事件。
|
| 166 |
+
- `error`:模型输出损坏或工具失败。
|
| 167 |
+
|
| 168 |
+
事件字段:
|
| 169 |
+
|
| 170 |
+
```json
|
| 171 |
+
{
|
| 172 |
+
"type": "stage",
|
| 173 |
+
"expression": "worried",
|
| 174 |
+
"motion": "gentle_blink",
|
| 175 |
+
"intensity": 0.7,
|
| 176 |
+
"duration_ms": 800
|
| 177 |
+
}
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
```json
|
| 181 |
+
{
|
| 182 |
+
"type": "voice",
|
| 183 |
+
"style": "soft",
|
| 184 |
+
"speed": 0.92,
|
| 185 |
+
"pitch": 1.04,
|
| 186 |
+
"energy": 0.45
|
| 187 |
+
}
|
| 188 |
+
```
|
| 189 |
+
|
| 190 |
+
```json
|
| 191 |
+
{
|
| 192 |
+
"type": "text_delta",
|
| 193 |
+
"text": "我在听。"
|
| 194 |
+
}
|
| 195 |
+
```
|
| 196 |
+
|
| 197 |
+
## Gradio 流式输出实现
|
| 198 |
+
|
| 199 |
+
Gradio 支持用 Python generator 做流式输出。事件处理函数不要 `return` 一次性结果,而是多次 `yield`。
|
| 200 |
+
|
| 201 |
+
输出组件建议:
|
| 202 |
+
|
| 203 |
+
```python
|
| 204 |
+
outputs = [
|
| 205 |
+
chatbot,
|
| 206 |
+
character_stage,
|
| 207 |
+
audio_output,
|
| 208 |
+
event_debug,
|
| 209 |
+
state,
|
| 210 |
+
]
|
| 211 |
+
```
|
| 212 |
+
|
| 213 |
+
伪代码:
|
| 214 |
+
|
| 215 |
+
```python
|
| 216 |
+
def respond_stream(user_text, chat_history, character_id, state):
|
| 217 |
+
ctx = build_context(user_text, chat_history, character_id, state)
|
| 218 |
+
|
| 219 |
+
partial_text = ""
|
| 220 |
+
sentence_buffer = ""
|
| 221 |
+
current_voice = default_voice(character_id)
|
| 222 |
+
stage_state = make_stage_state(character_id, expression="listening")
|
| 223 |
+
|
| 224 |
+
yield update(chat_history, stage_state, None, {"type": "stage", "expression": "listening"}, state)
|
| 225 |
+
|
| 226 |
+
for event in llm_event_stream(ctx):
|
| 227 |
+
if event["type"] == "stage":
|
| 228 |
+
stage_state = apply_stage_event(stage_state, event)
|
| 229 |
+
yield update(chat_history, stage_state, None, event, state)
|
| 230 |
+
|
| 231 |
+
elif event["type"] == "voice":
|
| 232 |
+
current_voice = merge_voice(current_voice, event)
|
| 233 |
+
yield update(chat_history, stage_state, None, event, state)
|
| 234 |
+
|
| 235 |
+
elif event["type"] == "text_delta":
|
| 236 |
+
partial_text += event["text"]
|
| 237 |
+
sentence_buffer += event["text"]
|
| 238 |
+
chat_history = set_assistant_partial(chat_history, partial_text)
|
| 239 |
+
yield update(chat_history, stage_state, None, event, state)
|
| 240 |
+
|
| 241 |
+
elif event["type"] == "sentence_end":
|
| 242 |
+
audio_chunk = synthesize_sentence(sentence_buffer, current_voice)
|
| 243 |
+
sentence_buffer = ""
|
| 244 |
+
stage_state = apply_stage_event(stage_state, {"type": "stage", "motion": "talk"})
|
| 245 |
+
yield update(chat_history, stage_state, audio_chunk, event, state)
|
| 246 |
+
|
| 247 |
+
elif event["type"] == "done":
|
| 248 |
+
stage_state = apply_stage_event(stage_state, {"type": "stage", "expression": "idle"})
|
| 249 |
+
yield update(chat_history, stage_state, None, event, state)
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
注意:
|
| 253 |
+
|
| 254 |
+
- Gradio generator 需要启用 queue。
|
| 255 |
+
- `gr.Audio(streaming=True, autoplay=True)` 可以接收后端逐块 yield 的音频。
|
| 256 |
+
- 音频块最好长度稳定,并且大于 1 秒,避免播放不稳定。
|
| 257 |
+
- 摄像头和麦克风输入可以用 `.stream()`,但第一版建议限制 `time_limit` 和 `stream_every`,避免占满队列。
|
| 258 |
+
|
| 259 |
+
## LLM 输出策略
|
| 260 |
+
|
| 261 |
+
### 方案 A:单模型直接输出事件流
|
| 262 |
+
|
| 263 |
+
一个模型直接输出 JSON event lines,再由后端包装成 SSE。
|
| 264 |
+
|
| 265 |
+
优点:
|
| 266 |
+
|
| 267 |
+
- 架构简单。
|
| 268 |
+
- 情绪、文字和动作天然同源。
|
| 269 |
+
|
| 270 |
+
缺点:
|
| 271 |
+
|
| 272 |
+
- 小模型可能输出非法 JSON 行。
|
| 273 |
+
- 需要严格 prompt、解析器和修复策略。
|
| 274 |
+
|
| 275 |
+
只适合实验。不要把它作为唯一生产路径。
|
| 276 |
+
|
| 277 |
+
### 方案 B:快规划器 + 文本流模型
|
| 278 |
+
|
| 279 |
+
先用一个很短的规划 prompt 生成首批控制事件:
|
| 280 |
+
|
| 281 |
+
```jsonl
|
| 282 |
+
{"type":"stage","expression":"thinking","motion":"look_at_user"}
|
| 283 |
+
{"type":"voice","style":"soft","speed":0.95}
|
| 284 |
+
{"type":"skill","name":"emotional_support"}
|
| 285 |
+
```
|
| 286 |
+
|
| 287 |
+
然后主模型流式输出文字,后端用规则或轻量分类器在分句处补充情绪事件。
|
| 288 |
+
|
| 289 |
+
优点:
|
| 290 |
+
|
| 291 |
+
- 页面响应更快。
|
| 292 |
+
- 首屏角色很快有动作。
|
| 293 |
+
- 对 JSON 合法性要求更低。
|
| 294 |
+
|
| 295 |
+
缺点:
|
| 296 |
+
|
| 297 |
+
- 情绪和文本可能不完全一致。
|
| 298 |
+
|
| 299 |
+
建议第一版采用 **B 的变体**:先快速输出一个初始 stage/voice/skill,再让主模型输出文本;后端在分句处补充情绪事件。这样响应最快,也最不容易被非法 JSON 卡住。
|
| 300 |
+
|
| 301 |
+
### 方案 C:纯文本流 + 后处理情绪分类
|
| 302 |
+
|
| 303 |
+
模型只流文本。后端每完成一句话,用轻量分类器或规则推断情绪。
|
| 304 |
+
|
| 305 |
+
优点:最稳。
|
| 306 |
+
|
| 307 |
+
缺点:不够“模型自己输出情绪”,角色动作也会滞后。
|
| 308 |
+
|
| 309 |
+
只作为 fallback。
|
| 310 |
+
|
| 311 |
+
## 解析和容错
|
| 312 |
+
|
| 313 |
+
模型事件流、Modal SSE、规则补充事件都必须经过 `stream_protocol.py`。
|
| 314 |
+
|
| 315 |
+
职责:
|
| 316 |
+
|
| 317 |
+
- 解析 SSE `data:` payload 或内部 NDJSON。
|
| 318 |
+
- 丢弃或修复不合法事件。
|
| 319 |
+
- 对未知字段做忽略,不让 UI 崩。
|
| 320 |
+
- 对缺失字段补默认值。
|
| 321 |
+
- 限制事件频率,防止 stage 抖动。
|
| 322 |
+
- 把模型输出事件归一化为前端可消费状态。
|
| 323 |
+
|
| 324 |
+
容错策略:
|
| 325 |
+
|
| 326 |
+
```text
|
| 327 |
+
非法 JSON 行 -> 作为 debug 记录,不进入舞台
|
| 328 |
+
未知 type -> debug
|
| 329 |
+
text_delta 缺 text -> 丢弃
|
| 330 |
+
stage 缺 expression/motion -> 使用当前状态
|
| 331 |
+
voice 参数越界 -> clamp
|
| 332 |
+
长时间没有 text_delta -> ��示 thinking 状态
|
| 333 |
+
模型结束但没有 done -> 后端补 done
|
| 334 |
+
```
|
| 335 |
+
|
| 336 |
+
## 角色舞台协议
|
| 337 |
+
|
| 338 |
+
后端不直接控制 Live2D 细节。后端只输出抽象状态:
|
| 339 |
+
|
| 340 |
+
```json
|
| 341 |
+
{
|
| 342 |
+
"expression": "worried",
|
| 343 |
+
"motion": "gentle_blink",
|
| 344 |
+
"mouth": "talking",
|
| 345 |
+
"gaze": "user",
|
| 346 |
+
"intensity": 0.7
|
| 347 |
+
}
|
| 348 |
+
```
|
| 349 |
+
|
| 350 |
+
`stage_driver.py` 负责映射:
|
| 351 |
+
|
| 352 |
+
- 静态头像/CSS:切图、晃动、嘴部光效。
|
| 353 |
+
- 2.5D HTML:表情层、CSS transform、嘴型动画。
|
| 354 |
+
- Live2D:expression、motion、parameter、hotkey。
|
| 355 |
+
|
| 356 |
+
这样以后替换表现层时,不需要改对话模型。
|
| 357 |
+
|
| 358 |
+
## TTS 流式策略
|
| 359 |
+
|
| 360 |
+
不要等全文结束再 TTS。使用分句级 TTS:
|
| 361 |
+
|
| 362 |
+
1. LLM 输出 `voice` 事件。
|
| 363 |
+
2. LLM 流式输出 `text_delta`。
|
| 364 |
+
3. 后端遇到中文句号、问号、叹号、换行,或模型输出 `sentence_end`。
|
| 365 |
+
4. 后端把当前句子送入 TTS。
|
| 366 |
+
5. TTS 产出音频块后立即 yield 到 `gr.Audio(streaming=True, autoplay=True)`。
|
| 367 |
+
6. 角色舞台进入 `talk` motion,音频结束后回 idle 或下一句 motion。
|
| 368 |
+
|
| 369 |
+
第一版可以不做真正逐 token TTS。分句 TTS 已经能显著降低等待感。
|
| 370 |
+
|
| 371 |
+
## 摄像头流式策略
|
| 372 |
+
|
| 373 |
+
第一版不做持续实时视频理解。推荐:
|
| 374 |
+
|
| 375 |
+
- 上传图片:稳定必做。
|
| 376 |
+
- 摄像头拍照:可选。
|
| 377 |
+
- 低频 stream:实验功能。
|
| 378 |
+
|
| 379 |
+
如果做 `.stream()`:
|
| 380 |
+
|
| 381 |
+
- `stream_every` 不要太小,建议先 1-3 秒。
|
| 382 |
+
- `time_limit` 必须设置。
|
| 383 |
+
- VLM 不要每帧都跑,可以抽样或节流。
|
| 384 |
+
- 视觉结果写入 `state.last_vision_note`,下一轮对话使用。
|
| 385 |
+
|
| 386 |
+
## 生图策略
|
| 387 |
+
|
| 388 |
+
生图只在后台资产生成或用户明确触发时运行。
|
| 389 |
+
|
| 390 |
+
流程:
|
| 391 |
+
|
| 392 |
+
```text
|
| 393 |
+
CharacterPackage.visual.image_prompt
|
| 394 |
+
-> image_engine
|
| 395 |
+
-> 角色头像/半身像/背景
|
| 396 |
+
-> assets/generated/
|
| 397 |
+
-> stage_driver 使用
|
| 398 |
+
```
|
| 399 |
+
|
| 400 |
+
用户自定义角色:
|
| 401 |
+
|
| 402 |
+
```text
|
| 403 |
+
上传参考图/描述
|
| 404 |
+
-> VLM 提取抽象视觉元素
|
| 405 |
+
-> LLM 原创化 visual prompt
|
| 406 |
+
-> 生图
|
| 407 |
+
-> 保存为新 CharacterPackage
|
| 408 |
+
```
|
| 409 |
+
|
| 410 |
+
## MVP 开发顺序
|
| 411 |
+
|
| 412 |
+
### Day 1:角色库和事件流
|
| 413 |
+
|
| 414 |
+
- 建 `CharacterPackage`。
|
| 415 |
+
- 建 3 个内置角色。
|
| 416 |
+
- 实现 `llm_event_stream` 的 mock 版本。
|
| 417 |
+
- 实现 `stream_protocol.py`。
|
| 418 |
+
- Gradio 页面能流式显示文本和事件调试。
|
| 419 |
+
|
| 420 |
+
### Day 2:角色舞台和 TTS
|
| 421 |
+
|
| 422 |
+
- 实现 `CharacterStage(gr.HTML)`。
|
| 423 |
+
- stage 支持 listening、thinking、talking、happy、worried。
|
| 424 |
+
- 接分句 TTS。
|
| 425 |
+
- `sentence_end` 后能播放音频并让嘴部动。
|
| 426 |
+
|
| 427 |
+
### Day 3:真实模型、视觉输入、生图
|
| 428 |
+
|
| 429 |
+
- 接真实 LLM 或 Inference Provider。
|
| 430 |
+
- 上传图片后用 VLM 生成 `vision_note`。
|
| 431 |
+
- 接生图生成默认头像或重绘头像。
|
| 432 |
+
|
| 433 |
+
### Day 4:打磨和演示
|
| 434 |
+
|
| 435 |
+
- 调整内置角色差异。
|
| 436 |
+
- 优化流式延迟。
|
| 437 |
+
- 加载一个 Live2D 或 2.5D 可动表现层作为加分项。
|
| 438 |
+
- 准备 README、示例对话和演示脚本。
|
| 439 |
+
|
| 440 |
+
## 必须避免
|
| 441 |
+
|
| 442 |
+
- 等完整 JSON 结束后才更新 UI。
|
| 443 |
+
- 每轮对话都跑生图。
|
| 444 |
+
- 直接克隆商业角色声音。
|
| 445 |
+
- 公开 Space 直接复刻商业角色图像、台词和官方设定。
|
| 446 |
+
- 把摄像头实时流作为第一版主链路。
|
| 447 |
+
- 让前端执行模型生成的任意 JavaScript。
|
| 448 |
+
|
| 449 |
+
## 官方能力依据
|
| 450 |
+
|
| 451 |
+
- Gradio 支持 Python generator 流式输出,可以反复 `yield` 组件值。
|
| 452 |
+
- Gradio `Audio(streaming=True, autoplay=True)` 支持后端逐块 yield 音频。
|
| 453 |
+
- Gradio `Image` 和 `Audio` 支持 `.stream()` 输入事件,适合摄像头和麦克风低频流。
|
| 454 |
+
- Gradio `.stream()` 应设置 `time_limit` 和 `stream_every`,避免单用户长期占用队列。
|
| 455 |
+
- Modal `fastapi_endpoint` 支持 FastAPI `StreamingResponse`,可以用 `text/event-stream` 直接发 SSE。
|
MODAL_DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Modal 部署与模型选择
|
| 2 |
+
|
| 3 |
+
## 目标
|
| 4 |
+
|
| 5 |
+
Modal 负责承载重模型和 GPU 推理,Hugging Face Space / Gradio 只负责交互界面、状态编排和轻量逻辑。
|
| 6 |
+
|
| 7 |
+
目标不是把所有模型常驻在 GPU 上,而是按需调用、缓存权重、控制冷启动和并发,尽量节省 hackathon 额度。
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
Gradio Space
|
| 11 |
+
-> Modal LLM / VLM endpoint
|
| 12 |
+
-> Modal TTS endpoint
|
| 13 |
+
-> Modal image generation endpoint
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
## 当前模型判断
|
| 17 |
+
|
| 18 |
+
### Google / Gemma 系列
|
| 19 |
+
|
| 20 |
+
截至 2026-06-11,Google 在 Hugging Face 上有最新的 Gemma 4 12B 规格:
|
| 21 |
+
|
| 22 |
+
- `google/gemma-4-12B-it`
|
| 23 |
+
- `google/gemma-4-12B`
|
| 24 |
+
|
| 25 |
+
其中 `google/gemma-4-12B-it` 是 Gemma 4 12B Unified instruction-tuned 模型。它支持文本、图片、音频、视频输入并生成文本输出,许可证是 Apache-2.0。这个模型非常适合本项目:它可以同时处理角色对话、图片理解和视频片段理解。当前页面先保留文字 + 图片输入,不接语音输入链路。
|
| 26 |
+
|
| 27 |
+
当前官方 Hugging Face 页面核到的 Gemma 4 规格是:
|
| 28 |
+
|
| 29 |
+
- `google/gemma-4-E2B-it`
|
| 30 |
+
- `google/gemma-4-E4B-it`
|
| 31 |
+
- `google/gemma-4-12B-it`
|
| 32 |
+
- `google/gemma-4-26B-A4B-it`
|
| 33 |
+
- `google/gemma-4-31B-it`
|
| 34 |
+
|
| 35 |
+
关键差异:
|
| 36 |
+
|
| 37 |
+
- E2B / E4B:支持文本、图片、音频输入,适合“音频直接进模型”的实验。
|
| 38 |
+
- 12B Unified:支持文本、图片、音频、视频输入;约 11.95B 参数,256K context;encoder-free unified multimodal 架构,适合作为主力中等规模模型。
|
| 39 |
+
- 26B A4B:MoE,总参数约 26B,活跃参数约 4B,支持文本和图片,适合主对话、角色推理、视觉理解,但不支持原生音频输入。
|
| 40 |
+
- 31B:文本和图片能力强,但更贵,不适合作为默认在线模型。
|
| 41 |
+
|
| 42 |
+
结论:
|
| 43 |
+
|
| 44 |
+
- `google/gemma-4-12B-it` 应该作为当前首选主模型候选。
|
| 45 |
+
- 当前改版暂不提供语音输入,先集中验证文字、图片和 TTS 输出。
|
| 46 |
+
- 如果后续重新评估语音输入,优先单独做一轮 Gemma 音频输入实验,不并入当前 TTS 交付。
|
| 47 |
+
- 如果想要更强文本/图像推理质量,再试 `gemma-4-26B-A4B-it`。
|
| 48 |
+
- 不建议默认上 31B,除非做离线评估或最终 demo 高质量路线。
|
| 49 |
+
|
| 50 |
+
### TTS 候选
|
| 51 |
+
|
| 52 |
+
#### Chatterbox
|
| 53 |
+
|
| 54 |
+
推荐作为第一优先级实验。
|
| 55 |
+
|
| 56 |
+
原因:
|
| 57 |
+
|
| 58 |
+
- 支持多语言,包括中文。
|
| 59 |
+
- 支持情绪夸张度 / intensity control。
|
| 60 |
+
- Hugging Face 模型页标 MIT。
|
| 61 |
+
- Modal 官方有 Chatterbox TTS API 示例。
|
| 62 |
+
- 很适合角色项目,因为可以把模型输出的 `voice.energy` / `emotion` 映射成 exaggeration、cfg、语速等参数。
|
| 63 |
+
|
| 64 |
+
注意:
|
| 65 |
+
|
| 66 |
+
- 有 voice cloning 能力,但公开 demo 不要克隆商业角色或真实声优。
|
| 67 |
+
- 可先使用内置 voice prompt 或原创 voice prompt。
|
| 68 |
+
|
| 69 |
+
#### Kokoro-82M
|
| 70 |
+
|
| 71 |
+
只适合作为省额度 fallback,不建议作为中文角色主 TTS。
|
| 72 |
+
|
| 73 |
+
原因:
|
| 74 |
+
|
| 75 |
+
- 82M,非常轻。
|
| 76 |
+
- Apache-2.0。
|
| 77 |
+
- 推理成本低,速度快。
|
| 78 |
+
|
| 79 |
+
限制:
|
| 80 |
+
|
| 81 |
+
- Hugging Face 模型页顶部标签偏 English,虽然模型事实里写 v1.0 是多语言。
|
| 82 |
+
- 中文路径依赖额外 G2P 包,实测暴露 `ordered_set` / `pypinyin` 等间接依赖问题。
|
| 83 |
+
- 角色表现力和中文稳定性都不如 Chatterbox Multilingual。
|
| 84 |
+
|
| 85 |
+
#### Dia-1.6B
|
| 86 |
+
|
| 87 |
+
适合作为表达力实验,不建议作为第一默认。
|
| 88 |
+
|
| 89 |
+
原因:
|
| 90 |
+
|
| 91 |
+
- 能生成对话式 TTS。
|
| 92 |
+
- 支持笑声、叹气等非语言表达。
|
| 93 |
+
|
| 94 |
+
限制:
|
| 95 |
+
|
| 96 |
+
- 模型页说明当前主要支持英文生成。
|
| 97 |
+
- 需要约 10GB VRAM。
|
| 98 |
+
- 也有 voice cloning 能力,公开 demo 要避免身份滥用。
|
| 99 |
+
|
| 100 |
+
#### Sesame CSM-1B
|
| 101 |
+
|
| 102 |
+
可作为研究候选。
|
| 103 |
+
|
| 104 |
+
原因:
|
| 105 |
+
|
| 106 |
+
- Conversational Speech Model,支持文本和音频上下文。
|
| 107 |
+
- Transformers 已支持。
|
| 108 |
+
|
| 109 |
+
限制:
|
| 110 |
+
|
| 111 |
+
- 模型 gated,需要接受访问条件。
|
| 112 |
+
- 上手和稳定性要单独验证。
|
| 113 |
+
|
| 114 |
+
### 语音输入策略
|
| 115 |
+
|
| 116 |
+
当前实现不提供语音输入,也不部署独立转写服务。第一阶段只做文字输入、图片输入和 TTS 输出,避免把交互问题和模型冷启动问题混在一起。
|
| 117 |
+
|
| 118 |
+
### 生图候选
|
| 119 |
+
|
| 120 |
+
推荐先用 FLUX.1-schnell。
|
| 121 |
+
|
| 122 |
+
原因:
|
| 123 |
+
|
| 124 |
+
- 适合快速生成角色头像、半身像、背景。
|
| 125 |
+
- Modal 官方有 Flux on H100 示例。
|
| 126 |
+
- `schnell` 步数少,适合按需生成。
|
| 127 |
+
|
| 128 |
+
使用原则:
|
| 129 |
+
|
| 130 |
+
- 不在每轮对话里调用。
|
| 131 |
+
- 只在角色资产生成、重绘、创建自定义角色时调用。
|
| 132 |
+
- 生成后缓存到本地或 Modal Volume,Gradio 直接读缓存图。
|
| 133 |
+
|
| 134 |
+
## Modal 服务拆分
|
| 135 |
+
|
| 136 |
+
建议拆成多个 Modal app 或多个 class,避免一个容器装所有模型。
|
| 137 |
+
|
| 138 |
+
### 1. `modal_llm.py`
|
| 139 |
+
|
| 140 |
+
用途:
|
| 141 |
+
|
| 142 |
+
- 角色对话。
|
| 143 |
+
- 图片理解。
|
| 144 |
+
- 输出 SSE 事件流。
|
| 145 |
+
|
| 146 |
+
候选模型:
|
| 147 |
+
|
| 148 |
+
- 省资源路线:`google/gemma-4-E4B-it`
|
| 149 |
+
- 主推路线:`google/gemma-4-12B-it`
|
| 150 |
+
- 高质量路线:`google/gemma-4-26B-A4B-it`
|
| 151 |
+
|
| 152 |
+
接口:
|
| 153 |
+
|
| 154 |
+
```http
|
| 155 |
+
POST /persona/events
|
| 156 |
+
Accept: text/event-stream
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
输出:
|
| 160 |
+
|
| 161 |
+
```text
|
| 162 |
+
data: {"type":"stage","expression":"thinking","motion":"look_at_user"}
|
| 163 |
+
|
| 164 |
+
data: {"type":"voice","style":"soft","speed":0.92,"energy":0.45}
|
| 165 |
+
|
| 166 |
+
data: {"type":"text_delta","text":"嗯,"}
|
| 167 |
+
|
| 168 |
+
data: {"type":"sentence_end"}
|
| 169 |
+
|
| 170 |
+
data: {"type":"done"}
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
### 2. `modal_tts.py`
|
| 174 |
+
|
| 175 |
+
用途:
|
| 176 |
+
|
| 177 |
+
- 分句 TTS。
|
| 178 |
+
- 返回 WAV/MP3 bytes。
|
| 179 |
+
- 可选支持流式音频。
|
| 180 |
+
|
| 181 |
+
候选模型:
|
| 182 |
+
|
| 183 |
+
- 默认:Chatterbox Multilingual。
|
| 184 |
+
- fallback:Kokoro-82M。
|
| 185 |
+
- 实验:Dia / CSM。
|
| 186 |
+
|
| 187 |
+
接口:
|
| 188 |
+
|
| 189 |
+
```http
|
| 190 |
+
POST /tts
|
| 191 |
+
```
|
| 192 |
+
|
| 193 |
+
输入:
|
| 194 |
+
|
| 195 |
+
```json
|
| 196 |
+
{
|
| 197 |
+
"text": "我在听。",
|
| 198 |
+
"voice_id": "star_knight_soft",
|
| 199 |
+
"emotion": "concerned",
|
| 200 |
+
"speed": 0.92,
|
| 201 |
+
"energy": 0.45
|
| 202 |
+
}
|
| 203 |
+
```
|
| 204 |
+
|
| 205 |
+
输出:
|
| 206 |
+
|
| 207 |
+
- `audio/wav` bytes
|
| 208 |
+
- 或 JSON 包含临时文件 URL
|
| 209 |
+
|
| 210 |
+
### 3. `modal_image.py`
|
| 211 |
+
|
| 212 |
+
用途:
|
| 213 |
+
|
| 214 |
+
- 生成内置角色图。
|
| 215 |
+
- 重绘角色头像。
|
| 216 |
+
- 自定义角色资产生成。
|
| 217 |
+
|
| 218 |
+
候选模型:
|
| 219 |
+
|
| 220 |
+
- FLUX.1-schnell
|
| 221 |
+
- SDXL fallback
|
| 222 |
+
|
| 223 |
+
接口:
|
| 224 |
+
|
| 225 |
+
```http
|
| 226 |
+
POST /image/character
|
| 227 |
+
```
|
| 228 |
+
|
| 229 |
+
## 省额度策略
|
| 230 |
+
|
| 231 |
+
### 模型路由
|
| 232 |
+
|
| 233 |
+
默认不要所有请求都打最大模型。
|
| 234 |
+
|
| 235 |
+
建议路由:
|
| 236 |
+
|
| 237 |
+
```text
|
| 238 |
+
纯文字日常聊天 -> E4B 或 12B
|
| 239 |
+
图片输入 -> Gemma 4 12B / E4B / 26B A4B
|
| 240 |
+
音频输入实验 -> Gemma 4 12B 或 E4B
|
| 241 |
+
中等成本角色回复 -> Gemma 4 12B
|
| 242 |
+
高质量角色回复 / 最终 demo -> 26B A4B
|
| 243 |
+
TTS -> Chatterbox 或 Kokoro
|
| 244 |
+
生图 -> 用户明确点击才调用
|
| 245 |
+
```
|
| 246 |
+
|
| 247 |
+
### 冷启动和缓存
|
| 248 |
+
|
| 249 |
+
必须使用 Modal Volume 缓存 Hugging Face 权重:
|
| 250 |
+
|
| 251 |
+
```text
|
| 252 |
+
/root/.cache/huggingface -> huggingface-cache volume
|
| 253 |
+
/root/.cache/vllm -> vllm-cache volume
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
服务参数建议:
|
| 257 |
+
|
| 258 |
+
- 开发期 `scaledown_window=60-180s`
|
| 259 |
+
- demo 录制期 `scaledown_window=5-15min`
|
| 260 |
+
- 公开 demo 期只让主 vLLM LLM 使用 `min_containers=1`,不要把 TTS、生图一起常驻
|
| 261 |
+
- 大模型用固定 revision,避免模型仓库更新导致不可复现
|
| 262 |
+
- 不要在 Gradio 启动时预热所有模型
|
| 263 |
+
- 只预热当前选择的角色和当前模型
|
| 264 |
+
|
| 265 |
+
主 vLLM 常驻开关:
|
| 266 |
+
|
| 267 |
+
```powershell
|
| 268 |
+
python scripts/set_modal_vllm_autoscaler.py on
|
| 269 |
+
python scripts/set_modal_vllm_autoscaler.py off
|
| 270 |
+
```
|
| 271 |
+
|
| 272 |
+
如果希望部署配置本身保持常驻,部署前设置:
|
| 273 |
+
|
| 274 |
+
```powershell
|
| 275 |
+
$env:VC_VLLM_MIN_CONTAINERS="1"
|
| 276 |
+
$env:VC_VLLM_BUFFER_CONTAINERS="0"
|
| 277 |
+
$env:VC_VLLM_SCALEDOWN_WINDOW="1200"
|
| 278 |
+
modal deploy modal_apps/modal_vllm_gemma.py
|
| 279 |
+
```
|
| 280 |
+
|
| 281 |
+
`scaledown_window` 只能减少短时间空闲后的冷启动;真正避免从零启动要使用 `min_containers=1`。这会让 GPU 24 小时计费。
|
| 282 |
+
|
| 283 |
+
### 一周常驻成本
|
| 284 |
+
|
| 285 |
+
按 Modal 2026-06-14 公开 GPU 价格,7 天是 168 小时:
|
| 286 |
+
|
| 287 |
+
| GPU | 约每小时 | 约 7 天 |
|
| 288 |
+
| --- | ---: | ---: |
|
| 289 |
+
| T4 | $0.5904 | $99.19 |
|
| 290 |
+
| L4 | $0.7992 | $134.27 |
|
| 291 |
+
| A10 | $1.1016 | $185.07 |
|
| 292 |
+
| L40S | $1.9512 | $327.80 |
|
| 293 |
+
| A100-40GB | $2.0988 | $352.60 |
|
| 294 |
+
| A100-80GB | $2.4984 | $419.73 |
|
| 295 |
+
| H100 | $3.9492 | $663.47 |
|
| 296 |
+
|
| 297 |
+
这些是 GPU-only 估算,CPU、内存、区域倍率、非抢占、Volume 存储等另计。当前已验证的 `google/gemma-4-12B-it` vLLM 路线使用 L40S,因此 7 天常驻只算 GPU 也超过 $240;$240 约能覆盖 L40S 123 小时。A10 一周 GPU-only 约 $185,但当前 12B vLLM 没有在 A10 上验证,显存余量风险较高。
|
| 298 |
+
|
| 299 |
+
## 当前 Modal 实测
|
| 300 |
+
|
| 301 |
+
2026-06-11 已在 `verno / veronicaulises0` Modal workspace 测试 `google/gemma-4-12B-it` + `L40S`:
|
| 302 |
+
|
| 303 |
+
- 首次运行:包含镜像构建和首次权重缓存,客户端总耗时约 149.1s。
|
| 304 |
+
- 缓存后运行:客户端总耗时约 33.7s;模型加载约 10.3s;生成 100 tokens 用时约 7.84s,约 12.75 tokens/s。
|
| 305 |
+
- 显存峰值:约 22.34 GB。
|
| 306 |
+
|
| 307 |
+
详细记录见 `BENCHMARK_RESULTS.md`。
|
| 308 |
+
|
| 309 |
+
vLLM 稳定版已尝试 `google/gemma-4-12B-it`,当前不可用。`0.21.0` 和 PyPI 最新稳定版 `0.22.1` 都会把模型解析为 `TransformersMultiModalForCausalLM`,提示没有 vLLM 原生实现并 fallback 到 Transformers,随后在 profile run 出现 shape mismatch。
|
| 310 |
+
|
| 311 |
+
截至 2026-06-12,Gemma 4 Unified 的可用 vLLM 路线是 main/nightly,而不是 PyPI 稳定版。当前已在 Modal L40S 上跑通 `vllm==0.22.1rc1.dev468+gfbc3a1907.cu129`,vLLM 日志解析架构为 `Gemma4UnifiedForConditionalGeneration`,并成功通过 OpenAI-compatible `/v1/chat/completions` 生成中文回复。
|
| 312 |
+
|
| 313 |
+
当前持久部署:
|
| 314 |
+
|
| 315 |
+
```text
|
| 316 |
+
https://veronicaulises0--virtual-characters-vllm-gemma-serve.modal.run
|
| 317 |
+
```
|
| 318 |
+
|
| 319 |
+
部署注意:
|
| 320 |
+
|
| 321 |
+
- 当前部署设置 `VC_SKIP_HF_SECRET=1`,没有把 `hf-token` 挂载到 nightly vLLM 环境。
|
| 322 |
+
- 能启动是因为 `vc-hf-cache` Modal Volume 中已有 `google/gemma-4-12B-it` 权重缓存。
|
| 323 |
+
- 如果清空 Volume 或迁移 workspace,需要先预缓存权重,或者明确批准 nightly 环境挂载 `hf-token`。
|
| 324 |
+
- 正式 endpoint 冷启动约 3 分钟;warm 后短中文回复实测约 10-16 tok/s。
|
| 325 |
+
|
| 326 |
+
短期策略:
|
| 327 |
+
|
| 328 |
+
- demo 主线可以切到当前 vLLM nightly endpoint,但要保留 Transformers Modal 服务作为 fallback。
|
| 329 |
+
- 如果继续追 vLLM 速度,优先优化当前 nightly endpoint 的冷启动、warmup 和 `--enforce-eager` 策略。
|
| 330 |
+
- 不要再浪费额度反复测试 `vllm==0.22.1` + `google/gemma-4-12B-it` 这一组合。
|
| 331 |
+
|
| 332 |
+
nightly 部署命令:
|
| 333 |
+
|
| 334 |
+
```powershell
|
| 335 |
+
$env:PYTHONIOENCODING="utf-8"
|
| 336 |
+
$env:PYTHONUTF8="1"
|
| 337 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 338 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 339 |
+
$env:VC_VLLM_PACKAGE="vllm==0.22.1rc1.dev468+gfbc3a1907.cu129"
|
| 340 |
+
$env:VC_VLLM_EXTRA_INDEX_URL="https://wheels.vllm.ai/nightly/cu129"
|
| 341 |
+
$env:VC_VLLM_UV_EXTRA_OPTIONS="--index-strategy unsafe-best-match"
|
| 342 |
+
$env:VC_VLLM_PRE="1"
|
| 343 |
+
$env:VC_VLLM_GPU="L40S"
|
| 344 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 345 |
+
.venv\Scripts\modal.exe deploy modal_apps/modal_vllm_gemma.py
|
| 346 |
+
```
|
| 347 |
+
|
| 348 |
+
TTS 已测试 Chatterbox 分句流式:底层不是音频 token 流式,但可以按句合成、按句播放。warm 后每句约 2-3 秒,足够做 demo。
|
| 349 |
+
|
| 350 |
+
### 并发
|
| 351 |
+
|
| 352 |
+
TTS 可以允许较高并发;LLM 需要保守。
|
| 353 |
+
|
| 354 |
+
建议初始值:
|
| 355 |
+
|
| 356 |
+
```text
|
| 357 |
+
LLM: max_inputs 4-16,按模型和 GPU 调
|
| 358 |
+
TTS: max_inputs 4-10
|
| 359 |
+
Image: max_inputs 1-2
|
| 360 |
+
```
|
| 361 |
+
|
| 362 |
+
### GPU 选择
|
| 363 |
+
|
| 364 |
+
初始建议:
|
| 365 |
+
|
| 366 |
+
- Chatterbox TTS:A10G / L4 起步。
|
| 367 |
+
- Kokoro:CPU / L4 / T4 均可试。
|
| 368 |
+
- Gemma 4 E4B:L4 / A10 / L40S 起步实测。
|
| 369 |
+
- Gemma 4 12B:L40S / A100 起步更稳,量化后可再评估更低规格。
|
| 370 |
+
- Gemma 4 26B A4B:A100 / H100 / H200 更稳,Modal 官方 vLLM 示例用了 H200。
|
| 371 |
+
- FLUX.1-schnell:H100 最快,但开发期可以不常驻,按需运行。
|
| 372 |
+
|
| 373 |
+
## 流式协议与 Modal
|
| 374 |
+
|
| 375 |
+
Modal 支持 FastAPI `StreamingResponse`。因此推荐 Modal 端直接输出 SSE:
|
| 376 |
+
|
| 377 |
+
```python
|
| 378 |
+
from fastapi.responses import StreamingResponse
|
| 379 |
+
|
| 380 |
+
def event_stream():
|
| 381 |
+
yield b'data: {"type":"stage","expression":"thinking"}\n\n'
|
| 382 |
+
yield b'data: {"type":"text_delta","text":"嗯,"}\n\n'
|
| 383 |
+
yield b'data: {"type":"done"}\n\n'
|
| 384 |
+
|
| 385 |
+
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
| 386 |
+
```
|
| 387 |
+
|
| 388 |
+
注意:
|
| 389 |
+
|
| 390 |
+
- 不要要求模型原生输出完美 NDJSON。
|
| 391 |
+
- 后端可以先发初始 stage/voice 事件,再转发模型 token。
|
| 392 |
+
- Gradio 端消费 SSE,转换成组件多次 `yield`。
|
| 393 |
+
- 调试时可以把 SSE payload 保存成 NDJSON 文件。
|
| 394 |
+
|
| 395 |
+
## Modal Secret
|
| 396 |
+
|
| 397 |
+
需要的 secret:
|
| 398 |
+
|
| 399 |
+
- `hf-token`:默认 Hugging Face token secret,用于下载 gated 或大模型权重。
|
| 400 |
+
- 可选 `modal-proxy-auth`:如果部署私有 endpoint。
|
| 401 |
+
|
| 402 |
+
如果 Modal 里已经有别的 Secret 名称,可以在运行或部署前设置:
|
| 403 |
+
|
| 404 |
+
```powershell
|
| 405 |
+
$env:VC_HF_SECRET_NAME="your-secret-name"
|
| 406 |
+
```
|
| 407 |
+
|
| 408 |
+
如果只是做 health smoke test,且还没有创建 HF Secret,可以临时设置:
|
| 409 |
+
|
| 410 |
+
```powershell
|
| 411 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 412 |
+
python scripts/check_modal_connectivity.py --mode remote-methods
|
| 413 |
+
```
|
| 414 |
+
|
| 415 |
+
这个开关默认只适合检查 Modal 容器启动。例外是当前 vLLM nightly + Gemma 4 路线:为了避免把 `hf-token` 暴露给 nightly 依赖栈,可以在确认 `vc-hf-cache` Volume 已经有完整权重缓存后使用 `VC_SKIP_HF_SECRET=1` 部署。常规稳定依赖栈仍建议创建 `hf-token`,并在 Hugging Face 上接受相关模型的访问条件。
|
| 416 |
+
|
| 417 |
+
注意不要把 token 写进仓库。
|
| 418 |
+
|
| 419 |
+
## 开发顺序
|
| 420 |
+
|
| 421 |
+
### 第一步:TTS endpoint
|
| 422 |
+
|
| 423 |
+
先做 Chatterbox 或 Kokoro,因为它最容易让 demo 有角色存在感。
|
| 424 |
+
|
| 425 |
+
产物:
|
| 426 |
+
|
| 427 |
+
- `modal_tts.py`
|
| 428 |
+
- `/tts` endpoint
|
| 429 |
+
- Gradio 调用 TTS 并播放音频
|
| 430 |
+
|
| 431 |
+
### 第二步:LLM event endpoint
|
| 432 |
+
|
| 433 |
+
先用 mock 事件流,再接 Gemma。
|
| 434 |
+
|
| 435 |
+
产物:
|
| 436 |
+
|
| 437 |
+
- `modal_llm.py`
|
| 438 |
+
- `/persona/events` SSE endpoint
|
| 439 |
+
- Gradio 能显示 text_delta、stage、voice、skill
|
| 440 |
+
|
| 441 |
+
### 第三步:图像输入
|
| 442 |
+
|
| 443 |
+
先用上传图片,不做实时摄像头。
|
| 444 |
+
|
| 445 |
+
产物:
|
| 446 |
+
|
| 447 |
+
- 图片传到 LLM/VLM endpoint
|
| 448 |
+
- 生成 `vision_note`
|
| 449 |
+
- 角色基于 `vision_note` 回复
|
| 450 |
+
|
| 451 |
+
### 第四步:生图
|
| 452 |
+
|
| 453 |
+
按需生成角色头像。
|
| 454 |
+
|
| 455 |
+
产物:
|
| 456 |
+
|
| 457 |
+
- `modal_image.py`
|
| 458 |
+
- `/image/character`
|
| 459 |
+
- 缓存生成结果
|
| 460 |
+
|
| 461 |
+
## 参考链接
|
| 462 |
+
|
| 463 |
+
- Modal vLLM / Gemma 示例: https://modal.com/docs/examples/vllm_inference
|
| 464 |
+
- Modal streaming endpoints: https://modal.com/docs/guide/streaming-endpoints
|
| 465 |
+
- Modal GPU: https://modal.com/docs/guide/gpu
|
| 466 |
+
- Modal Volumes: https://modal.com/docs/guide/volumes
|
| 467 |
+
- Modal Chatterbox TTS: https://modal.com/docs/examples/chatterbox_tts
|
| 468 |
+
- Modal Flux: https://modal.com/docs/examples/flux
|
| 469 |
+
- Gemma 4 12B IT: https://huggingface.co/google/gemma-4-12B-it
|
| 470 |
+
- Gemma 4 26B A4B: https://huggingface.co/google/gemma-4-26B-A4B-it
|
| 471 |
+
- Gemma 4 E4B: https://huggingface.co/google/gemma-4-E4B-it
|
| 472 |
+
- vLLM PyPI: https://pypi.org/project/vllm/
|
| 473 |
+
- vLLM supported models latest docs: https://docs.vllm.ai/en/latest/models/supported_models/
|
| 474 |
+
- vLLM nightly wheels: https://docs.vllm.ai/en/latest/contributing/ci/nightly_builds/
|
| 475 |
+
- Kokoro-82M: https://huggingface.co/hexgrad/Kokoro-82M
|
| 476 |
+
- Chatterbox: https://huggingface.co/ResembleAI/chatterbox
|
| 477 |
+
- Dia-1.6B: https://huggingface.co/nari-labs/Dia-1.6B
|
| 478 |
+
- Sesame CSM-1B: https://huggingface.co/sesame/csm-1b
|
PROJECT_DESIGN.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 虚拟角色项目总览
|
| 2 |
+
|
| 3 |
+
## 当前结论
|
| 4 |
+
|
| 5 |
+
这个项目的主线是 **现有角色驱动的多模态虚拟人格系统**。
|
| 6 |
+
|
| 7 |
+
用户第一眼看到的应该是可选角色,而不是角色创建器。用户选中角色后,系统进入对话、语音、视觉理解和角色舞台联动体验。角色创建、生图、资料提取、prompt 组装和 skill 配置都是后台能力。
|
| 8 |
+
|
| 9 |
+
核心体验:
|
| 10 |
+
|
| 11 |
+
```text
|
| 12 |
+
选择角色 -> 文本/语音/图片/摄像头输入 -> 模型流式输出回复、情绪、动作和语音参数 -> TTS/角色舞台同步响应
|
| 13 |
+
```
|
| 14 |
+
|
| 15 |
+
## 文档结构
|
| 16 |
+
|
| 17 |
+
- [RESEARCH_NOTES.md](RESEARCH_NOTES.md):调研信息、参考项目、产品判断、风险分析。
|
| 18 |
+
- [DEVELOPMENT_GUIDE.md](DEVELOPMENT_GUIDE.md):后续开发必须遵循的架构、文件结构、流式协议、Gradio 事件设计和 MVP 范围。
|
| 19 |
+
- [MODAL_DEPLOYMENT.md](MODAL_DEPLOYMENT.md):Modal 上的模型部署、调用方式、模型候选和省额度策略。
|
| 20 |
+
|
| 21 |
+
## 关键产品原则
|
| 22 |
+
|
| 23 |
+
1. 现有角色优先
|
| 24 |
+
MVP 第一屏是角色选择。自定义角色和角色生成放在高级入口。
|
| 25 |
+
|
| 26 |
+
2. 情绪由模型输出
|
| 27 |
+
不靠用户按钮硬切情绪。模型每轮回复要输出表情、动作、语气、skill 等控制信息。
|
| 28 |
+
|
| 29 |
+
3. 不等完整 JSON 才开始动
|
| 30 |
+
对话输出使用事件流协议。模型可以先输出 `stage` / `emotion` / `voice` 事件,让 Live2D 或 2.5D 舞台先变化,再流式输出文本和音频。
|
| 31 |
+
|
| 32 |
+
4. 生图是资产生成模块
|
| 33 |
+
生图用于内置角色头像、半身像、背景图、自定义角色重绘,不在每轮聊天里触发。
|
| 34 |
+
|
| 35 |
+
5. 视觉模型要绑定角色人格
|
| 36 |
+
摄像头或上传图片不是普通看图问答,而是“当前角色如何看见并回应这件事”。
|
| 37 |
+
|
| 38 |
+
6. 公开 demo 要原创化
|
| 39 |
+
本地探索可以参考 Amadeus、流萤等目标体验。公开 HF Space 建议使用原创化角色,避免直接使用商业角色名、图像、台词、声音或完整官方设定。
|
| 40 |
+
|
| 41 |
+
## MVP 目标
|
| 42 |
+
|
| 43 |
+
第一版要证明“角色存在感”,不是证明所有模型都最强。
|
| 44 |
+
|
| 45 |
+
必做:
|
| 46 |
+
|
| 47 |
+
- 至少 3 个内置角色。
|
| 48 |
+
- 文字聊天。
|
| 49 |
+
- 模型流式输出:回复文本、情绪、动作、voice 参数、skill。
|
| 50 |
+
- TTS 播放。
|
| 51 |
+
- 角色舞台根据模型事件实时变化。
|
| 52 |
+
- 上传图片后,角色以自身人格评论。
|
| 53 |
+
- 调试面板展示事件流和模型结构化输出。
|
| 54 |
+
|
| 55 |
+
可选:
|
| 56 |
+
|
| 57 |
+
- 摄像头拍照分析。
|
| 58 |
+
- 生图重绘角色头像。
|
| 59 |
+
- Live2D Web 模型加载。
|
| 60 |
+
|
| 61 |
+
暂缓:
|
| 62 |
+
|
| 63 |
+
- 全实时视频对话。
|
| 64 |
+
- 复杂长期记忆。
|
| 65 |
+
- 声音克隆。
|
| 66 |
+
- 商业角色原样复刻。
|
| 67 |
+
- 单图自动 rig 成 Live2D。
|
README.md
CHANGED
|
@@ -1,13 +1,158 @@
|
|
| 1 |
---
|
| 2 |
title: Virtual Characters
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Virtual Characters
|
| 3 |
+
emoji: 🎭
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: pink
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.17.3
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
+
hf_oauth: true
|
| 10 |
+
tags:
|
| 11 |
+
- build-small-hackathon
|
| 12 |
+
- small-models
|
| 13 |
+
- virtual-character
|
| 14 |
+
- multimodal
|
| 15 |
+
- tts
|
| 16 |
+
- modal
|
| 17 |
+
- gradio
|
| 18 |
+
- "track:backyard"
|
| 19 |
+
- "sponsor:modal"
|
| 20 |
+
- "achievement:offbrand"
|
| 21 |
+
- "achievement:fieldnotes"
|
| 22 |
---
|
| 23 |
|
| 24 |
+
# Virtual Characters
|
| 25 |
+
|
| 26 |
+
Virtual Characters is a small-model, multimodal companion demo for the Build Small Hackathon. The current public experience focuses on one original character, **星萤**, with a live stage, expression switching, image-aware chat, and optional TTS playback.
|
| 27 |
+
|
| 28 |
+
The app is built as a Hugging Face Gradio Space. Heavy inference runs on Modal endpoints, while the Space handles UI state, character cards, model status, OAuth-backed workshop persistence, and the stage renderer.
|
| 29 |
+
|
| 30 |
+
## Why It Fits Build Small
|
| 31 |
+
|
| 32 |
+
- **All active models are under 32B parameters.** The main chat endpoint is configured around a 12B-class Gemma model served by vLLM on Modal. The TTS path uses lightweight speech models such as Chatterbox/Kokoro-class voices. The image-generation spike and workshop path evaluate Qwen-Image/FLUX-style models as optional asset-generation services, also below the 32B ceiling.
|
| 33 |
+
- **No 70B+ or giant hosted assistant is used as the core runtime.** The project is designed around smaller specialized services: chat, voice, image generation, matting, and stage control.
|
| 34 |
+
- **Original/off-brand character design.** 星萤 is an original sci-fi communication-room character, not a clone of a commercial character.
|
| 35 |
+
- **Field notes included.** The repo keeps benchmark notes and implementation notes for Modal cold starts, warm latency, TTS, image generation, and character-pack feasibility.
|
| 36 |
+
|
| 37 |
+
## What You Can Try
|
| 38 |
+
|
| 39 |
+
- Chat with 星萤 through text and image uploads.
|
| 40 |
+
- Watch the stage switch expressions and motions from model output tags.
|
| 41 |
+
- Generate playable TTS replies when the Modal TTS endpoint is available.
|
| 42 |
+
- Open the `角色工坊` tab to draft/import Tavern-style character cards and test the asset-generation workflow.
|
| 43 |
+
- Check model status cards for LLM, TTS, and image generation. If a Modal endpoint is asleep, the UI tells users to wait for cold start/model loading instead of failing silently.
|
| 44 |
+
|
| 45 |
+
## Runtime Architecture
|
| 46 |
+
|
| 47 |
+
```text
|
| 48 |
+
Hugging Face Space (Gradio)
|
| 49 |
+
├─ chat UI, stage renderer, role card UI
|
| 50 |
+
├─ HF OAuth login for workshop save/resume
|
| 51 |
+
├─ model status checks
|
| 52 |
+
└─ Modal endpoints
|
| 53 |
+
├─ vLLM chat: 12B-class Gemma endpoint
|
| 54 |
+
├─ TTS: Chatterbox/Kokoro-class voices
|
| 55 |
+
└─ image generation spike/workshop: Qwen-Image / FLUX candidates
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
## Model Notes
|
| 59 |
+
|
| 60 |
+
The current configured stack is intentionally small-model oriented:
|
| 61 |
+
|
| 62 |
+
| Capability | Model / Service | Size Policy |
|
| 63 |
+
| --- | --- | --- |
|
| 64 |
+
| Dialogue | `google/gemma-4-12B-it` served through Modal vLLM | 12B-class, below 32B |
|
| 65 |
+
| TTS | Chatterbox/Kokoro-class Modal services | lightweight speech models, below 32B |
|
| 66 |
+
| Character image spike | `Qwen/Qwen-Image`, `Qwen/Qwen-Image-Edit`, `FLUX.1-schnell` candidates | optional asset-generation services, below 32B target |
|
| 67 |
+
| Background removal | `rembg` CPU matting path | local utility model, below 32B |
|
| 68 |
+
|
| 69 |
+
The app exposes status cards because Modal endpoints can sleep. A sleeping endpoint is expected during demos; click refresh or retry after cold start completes.
|
| 70 |
+
|
| 71 |
+
## Project Structure
|
| 72 |
+
|
| 73 |
+
- `app.py`: Gradio UI, tabs, chat, workshop wiring, and model status controls.
|
| 74 |
+
- `src/character_registry.py`: built-in character registry. The public demo currently ships only 星萤.
|
| 75 |
+
- `src/stage_driver.py`: HTML/CSS stage renderer and expression/motion asset selection.
|
| 76 |
+
- `src/dialogue_engine.py`: vLLM/OpenAI-compatible streaming, stage tag parsing, and TTS event handling.
|
| 77 |
+
- `src/character_workshop.py`: Tavern JSON/form draft import, HF-login scoped save/resume, generation packaging, and install flow.
|
| 78 |
+
- `src/model_status.py`: LLM/TTS/image-generation health checks.
|
| 79 |
+
- `modal_apps/`: Modal deployment scripts for chat, TTS, and image-generation spikes.
|
| 80 |
+
- `CHARACTER_GENERATION_SPIKE.md`: risk validation report for the character-generation pipeline.
|
| 81 |
+
- `BENCHMARK_RESULTS.md`: Modal latency and deployment notes.
|
| 82 |
+
|
| 83 |
+
## Local Development
|
| 84 |
+
|
| 85 |
+
```powershell
|
| 86 |
+
python -m pip install -r requirements.txt
|
| 87 |
+
.\scripts\restart_gradio_background.ps1
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
Default local URL:
|
| 91 |
+
|
| 92 |
+
```text
|
| 93 |
+
http://127.0.0.1:7864
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
The restart script launches Gradio in the background and prints the PID, URL, stdout log path, stderr log path, LLM endpoint, and TTS endpoint.
|
| 97 |
+
|
| 98 |
+
Mock mode avoids remote Modal calls:
|
| 99 |
+
|
| 100 |
+
```powershell
|
| 101 |
+
.\scripts\start_gradio_background.ps1 -Mock
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
Override endpoints when needed:
|
| 105 |
+
|
| 106 |
+
```powershell
|
| 107 |
+
.\scripts\start_gradio_background.ps1 -VllmUrl "https://your-vllm-endpoint.modal.run"
|
| 108 |
+
.\scripts\restart_gradio_background.ps1 -TtsUrl "https://your-tts-endpoint.modal.run"
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
## Character Assets
|
| 112 |
+
|
| 113 |
+
The public build includes one built-in character:
|
| 114 |
+
|
| 115 |
+
```text
|
| 116 |
+
assets/characters/star/
|
| 117 |
+
assets/backgrounds/communication_room.png
|
| 118 |
+
```
|
| 119 |
+
|
| 120 |
+
Supported expression slots:
|
| 121 |
+
|
| 122 |
+
```text
|
| 123 |
+
idle, listening, thinking, worried, smile, happy
|
| 124 |
+
```
|
| 125 |
+
|
| 126 |
+
Motion-specific assets:
|
| 127 |
+
|
| 128 |
+
```text
|
| 129 |
+
assets/characters/star/talk.png
|
| 130 |
+
assets/characters/star/focus.png
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
When the model emits `motion=talk` or `motion=focus`, `src/stage_driver.py` uses the dedicated action sprite. Other motions fall back to the current expression image.
|
| 134 |
+
|
| 135 |
+
## Character Workshop
|
| 136 |
+
|
| 137 |
+
The workshop is deliberately separated from the chat tab. Users can:
|
| 138 |
+
|
| 139 |
+
1. Import a Tavern-style JSON card or fill a form.
|
| 140 |
+
2. Generate four independent main-visual candidates.
|
| 141 |
+
3. Select one candidate.
|
| 142 |
+
4. Generate eight expression/action slots and one background.
|
| 143 |
+
5. Run background removal and package assets for the stage driver.
|
| 144 |
+
6. Install the generated character locally into the runtime registry.
|
| 145 |
+
|
| 146 |
+
On Hugging Face Spaces, generation and install actions require HF OAuth login so each user's runs can be saved and resumed separately.
|
| 147 |
+
|
| 148 |
+
## Modal Character Generation Spike
|
| 149 |
+
|
| 150 |
+
The image-generation flow is still marked as an MVP/workshop path, not the core dependency for chatting. The spike scripts can be run separately:
|
| 151 |
+
|
| 152 |
+
```powershell
|
| 153 |
+
python scripts/run_character_generation_spike.py list-models
|
| 154 |
+
python scripts/run_character_generation_spike.py modal-health
|
| 155 |
+
python scripts/run_character_generation_spike.py modal-probe --candidate qwen_image --batch-size 4 --confirm-gpu
|
| 156 |
+
```
|
| 157 |
+
|
| 158 |
+
For full notes, see `CHARACTER_GENERATION_SPIKE.md`.
|
RESEARCH_NOTES.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 调研与思考记录
|
| 2 |
+
|
| 3 |
+
## 参考对象
|
| 4 |
+
|
| 5 |
+
### Amadeus 类虚拟人格
|
| 6 |
+
|
| 7 |
+
《Steins;Gate 0》里的 Amadeus 不是普通 chatbot。它的关键点是:
|
| 8 |
+
|
| 9 |
+
- 角色人格来自某个人的记忆和人格数据。
|
| 10 |
+
- 用户通过手机式通讯界面与角色交流。
|
| 11 |
+
- 体验重点是“持续存在的人格容器”,而不是通用问答工具。
|
| 12 |
+
|
| 13 |
+
对本项目的启发:
|
| 14 |
+
|
| 15 |
+
- UI 可以借鉴通讯窗口、视频通话窗口、角色状态灯。
|
| 16 |
+
- 角色需要身份、关系感、记忆边界和情绪连续性。
|
| 17 |
+
- 角色不应该每轮都像第一次见用户。
|
| 18 |
+
|
| 19 |
+
### Open-LLM-VTuber
|
| 20 |
+
|
| 21 |
+
Open-LLM-VTuber 是当前最接近本项目系统形态的参考。它把 LLM、ASR、TTS、视觉感知、Live2D avatar、情绪映射、角色自定义、聊天记录持久化等模块组织在一起。
|
| 22 |
+
|
| 23 |
+
可借鉴点:
|
| 24 |
+
|
| 25 |
+
- 后端输出情绪,前端映射到 Live2D 表情。
|
| 26 |
+
- 支持摄像头、屏幕录制、截图,让 AI 角色获得视觉感知。
|
| 27 |
+
- 角色不是单 prompt,而是一组配置、模型、交互通道和表现层。
|
| 28 |
+
|
| 29 |
+
### VTube Studio API
|
| 30 |
+
|
| 31 |
+
VTube Studio 说明成熟 2.5D 角色系统通常不是“生成一段视频”,而是把外部信号映射到角色参数:
|
| 32 |
+
|
| 33 |
+
- 通过 WebSocket 触发 hotkey。
|
| 34 |
+
- 控制模型、表情、动作。
|
| 35 |
+
- 输入 face tracking 数据。
|
| 36 |
+
- 修改部分 ArtMesh 颜色。
|
| 37 |
+
|
| 38 |
+
对本项目的启发:AI 输出应该是 `expression`、`motion`、`parameter` 这类控制事件,而不是一整段不可交互的视频。
|
| 39 |
+
|
| 40 |
+
### pixi-live2d-display
|
| 41 |
+
|
| 42 |
+
pixi-live2d-display 是 Web 端 Live2D 展示参考。它适合放在 `gr.HTML` 里作为浏览器端角色舞台方案。
|
| 43 |
+
|
| 44 |
+
适用场景:
|
| 45 |
+
|
| 46 |
+
- 已有公开授权 Live2D 模型。
|
| 47 |
+
- 后端输出表情和动作事件。
|
| 48 |
+
- 前端把事件映射到 Live2D motion/expression/parameter。
|
| 49 |
+
|
| 50 |
+
### AnimateAnyone / Hallo
|
| 51 |
+
|
| 52 |
+
这类项目证明单图驱动角色动画或音频驱动人像动画是可行方向,但它们更像后期增强模块:
|
| 53 |
+
|
| 54 |
+
- 生成成本高。
|
| 55 |
+
- 延迟不适合第一版交互。
|
| 56 |
+
- 更适合生成短视频片段,不适合低延迟角色陪伴。
|
| 57 |
+
|
| 58 |
+
## 内置角色策略
|
| 59 |
+
|
| 60 |
+
### 本地探索角色
|
| 61 |
+
|
| 62 |
+
本地探索可以直接参考:
|
| 63 |
+
|
| 64 |
+
- Amadeus / 牧濑红莉栖风格。
|
| 65 |
+
- 流萤风格。
|
| 66 |
+
- 其他你喜欢的游戏或动画角色。
|
| 67 |
+
|
| 68 |
+
目的不是公开复刻,而是快速评估角色还原感、语气、情绪输出和舞台表现。
|
| 69 |
+
|
| 70 |
+
### 公开提交角色
|
| 71 |
+
|
| 72 |
+
公开 HF Space 建议原创化:
|
| 73 |
+
|
| 74 |
+
- `Amadeus-like Memory Girl`:记忆人格、理性、研究员、通讯界面感。
|
| 75 |
+
- `Firefly-inspired Star Knight`:柔弱外表和战斗身份反差,科幻装甲,守护欲。
|
| 76 |
+
- `Lab Assistant`:偏理性吐槽型。
|
| 77 |
+
- `Mischief Mascot`:更轻松、有趣,适合 Thousand Token Wood。
|
| 78 |
+
|
| 79 |
+
原因:直接使用商业角色名、官方图像、官方声音、官方台词和完整剧情设定会带来 IP 风险。
|
| 80 |
+
|
| 81 |
+
## 生图模块判断
|
| 82 |
+
|
| 83 |
+
生图应该是后台资产生成能力,而不是每轮对话都调用。
|
| 84 |
+
|
| 85 |
+
适合场景:
|
| 86 |
+
|
| 87 |
+
- 内置角色头像、半身像、背景图生成。
|
| 88 |
+
- 用户点击“重绘角色”。
|
| 89 |
+
- 自定义角色创建时,把用户上传图或描述转成原创视觉资产。
|
| 90 |
+
|
| 91 |
+
推荐方向:
|
| 92 |
+
|
| 93 |
+
- `FLUX.1-schnell`:速度快,许可证友好,适合快速生成角色图。
|
| 94 |
+
- `SDXL`:生态成熟,动漫风格资源多,适合后续做 LoRA / ControlNet / IP-Adapter。
|
| 95 |
+
- 角色一致性增强:后续再看 IP-Adapter、InstantID、PuLID、角色 LoRA。
|
| 96 |
+
|
| 97 |
+
不建议:
|
| 98 |
+
|
| 99 |
+
- 每轮聊天都生图。
|
| 100 |
+
- 直接复刻商业角色图。
|
| 101 |
+
- 把生图作为主体验阻塞对话。
|
| 102 |
+
|
| 103 |
+
## 摄像头与视觉模型判断
|
| 104 |
+
|
| 105 |
+
视觉模型可以成为差异点,但第一版要克制。
|
| 106 |
+
|
| 107 |
+
可做场景:
|
| 108 |
+
|
| 109 |
+
1. 角色看见你
|
| 110 |
+
用户用摄像头拍照或低频抽帧,VLM 判断用户是否在镜头前、大致情绪、场景氛围。角色以自身人格回应。
|
| 111 |
+
|
| 112 |
+
2. 角色看见物品
|
| 113 |
+
用户把书、玩偶、手办、饮料等拿给摄像头看,角色进行 persona 化评论。
|
| 114 |
+
|
| 115 |
+
3. 角色看见图片或截图
|
| 116 |
+
用户上传截图,角色不是做通用图像问答,而是用自己的身份和情绪解读。
|
| 117 |
+
|
| 118 |
+
MVP 建议:先做上传图片和手动拍照分析。实时流摄像头会增加队列、延迟和成本压力。
|
| 119 |
+
|
| 120 |
+
## 参考资料
|
| 121 |
+
|
| 122 |
+
- Steins;Gate 0 / Amadeus 设定概览: https://en.wikipedia.org/wiki/Steins%3BGate_0
|
| 123 |
+
- Open-LLM-VTuber: https://github.com/Open-LLM-VTuber/Open-LLM-VTuber
|
| 124 |
+
- VTube Studio API: https://github.com/DenchiSoft/VTubeStudio
|
| 125 |
+
- pixi-live2d-display: https://github.com/guansss/pixi-live2d-display
|
| 126 |
+
- Live2D 概念: https://en.wikipedia.org/wiki/Live2D
|
| 127 |
+
- AnimateAnyone: https://github.com/HumanAIGC/AnimateAnyone
|
| 128 |
+
- Hallo: https://github.com/fudan-generative-vision/hallo
|
| 129 |
+
- Gradio Image / webcam: https://www.gradio.app/docs/gradio/image
|
| 130 |
+
- Gradio streaming inputs: https://www.gradio.app/guides/streaming-inputs
|
| 131 |
+
- Gradio streaming outputs: https://www.gradio.app/guides/streaming-outputs
|
| 132 |
+
- Hugging Face Inference Providers: https://huggingface.co/docs/inference-providers/index
|
| 133 |
+
- FLUX.1-schnell: https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
| 134 |
+
- SDXL: https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0
|
| 135 |
+
|
app.py
ADDED
|
@@ -0,0 +1,1877 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import html
|
| 5 |
+
import mimetypes
|
| 6 |
+
import os
|
| 7 |
+
import time
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import gradio as gr
|
| 12 |
+
|
| 13 |
+
from src.character_registry import (
|
| 14 |
+
character_display_name,
|
| 15 |
+
format_character_card_markdown,
|
| 16 |
+
get_character,
|
| 17 |
+
get_character_packages,
|
| 18 |
+
)
|
| 19 |
+
from src.character_workshop import (
|
| 20 |
+
LOGIN_REQUIRED_MESSAGE,
|
| 21 |
+
create_draft_from_form,
|
| 22 |
+
create_draft_from_tavern_json,
|
| 23 |
+
create_initial_state,
|
| 24 |
+
ensure_user_workshop_run,
|
| 25 |
+
generate_background,
|
| 26 |
+
generate_expression_pack,
|
| 27 |
+
generate_main_candidates,
|
| 28 |
+
get_current_user,
|
| 29 |
+
install_character_package,
|
| 30 |
+
list_user_workshop_runs,
|
| 31 |
+
load_workshop_run,
|
| 32 |
+
matte_and_package_assets,
|
| 33 |
+
record_workshop_event,
|
| 34 |
+
render_packaged_stage_preview,
|
| 35 |
+
require_login_for_generation,
|
| 36 |
+
select_main_candidate,
|
| 37 |
+
summarize_workshop_stats,
|
| 38 |
+
)
|
| 39 |
+
from src.dialogue_engine import stream_reply
|
| 40 |
+
from src.model_status import (
|
| 41 |
+
check_all_statuses,
|
| 42 |
+
check_image_generation_status,
|
| 43 |
+
initial_model_statuses,
|
| 44 |
+
llm_loading_status,
|
| 45 |
+
statuses_json,
|
| 46 |
+
statuses_markdown,
|
| 47 |
+
statuses_with_llm_status,
|
| 48 |
+
warm_llm_model,
|
| 49 |
+
)
|
| 50 |
+
from src.stage_driver import render_character_stage
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
ROOT = Path(__file__).resolve().parent
|
| 54 |
+
|
| 55 |
+
APP_CSS = """
|
| 56 |
+
:root,
|
| 57 |
+
body {
|
| 58 |
+
color-scheme: light;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
.gradio-container {
|
| 62 |
+
background:
|
| 63 |
+
radial-gradient(circle at 10% 8%, rgba(103, 232, 249, .22), transparent 28%),
|
| 64 |
+
radial-gradient(circle at 82% 0%, rgba(251, 191, 36, .20), transparent 26%),
|
| 65 |
+
linear-gradient(135deg, #eef7f8 0%, #f8f4ec 46%, #f4eef2 100%);
|
| 66 |
+
color: #172033;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
#vc-root {
|
| 70 |
+
max-width: 1500px;
|
| 71 |
+
margin: 0 auto;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
.vc-topbar {
|
| 75 |
+
align-items: stretch;
|
| 76 |
+
flex-wrap: wrap;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
.vc-status-wrap {
|
| 80 |
+
min-width: min(760px, 100%);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.vc-status-actions {
|
| 84 |
+
justify-content: center;
|
| 85 |
+
gap: 8px;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.vc-status-actions button {
|
| 89 |
+
width: 100%;
|
| 90 |
+
min-height: 38px;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.vc-model-action {
|
| 94 |
+
border: 1px solid rgba(212, 212, 216, .13);
|
| 95 |
+
border-radius: 8px;
|
| 96 |
+
background: rgba(18, 20, 24, .66);
|
| 97 |
+
padding: 10px 12px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.vc-model-action p {
|
| 101 |
+
margin: 0;
|
| 102 |
+
color: rgba(244, 244, 245, .82);
|
| 103 |
+
font-size: 13px;
|
| 104 |
+
line-height: 1.45;
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
.vc-tabs {
|
| 108 |
+
margin-top: 8px;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.vc-panel {
|
| 112 |
+
border: 1px solid rgba(212, 212, 216, .13);
|
| 113 |
+
border-radius: 8px;
|
| 114 |
+
background: rgba(18, 20, 24, .82);
|
| 115 |
+
box-shadow: 0 18px 52px rgba(0, 0, 0, .24);
|
| 116 |
+
backdrop-filter: blur(18px);
|
| 117 |
+
padding: 12px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.vc-panel .label-wrap,
|
| 121 |
+
.vc-input-dock .label-wrap {
|
| 122 |
+
color: rgba(228, 228, 231, .82);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.vc-card-head {
|
| 126 |
+
display: grid;
|
| 127 |
+
grid-template-columns: 72px 1fr;
|
| 128 |
+
gap: 12px;
|
| 129 |
+
align-items: center;
|
| 130 |
+
padding: 4px 0 12px;
|
| 131 |
+
border-bottom: 1px solid rgba(212, 212, 216, .12);
|
| 132 |
+
margin-bottom: 12px;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.vc-card-head img {
|
| 136 |
+
width: 72px;
|
| 137 |
+
height: 72px;
|
| 138 |
+
object-fit: cover;
|
| 139 |
+
border-radius: 8px;
|
| 140 |
+
background: #09090b;
|
| 141 |
+
border: 1px solid rgba(103, 232, 249, .30);
|
| 142 |
+
box-shadow: 0 0 26px rgba(103, 232, 249, .14);
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
.vc-card-title {
|
| 146 |
+
display: flex;
|
| 147 |
+
flex-direction: column;
|
| 148 |
+
gap: 7px;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
.vc-card-title strong {
|
| 152 |
+
font-size: 18px;
|
| 153 |
+
letter-spacing: 0;
|
| 154 |
+
color: #f4f4f5;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.vc-tags {
|
| 158 |
+
display: flex;
|
| 159 |
+
flex-wrap: wrap;
|
| 160 |
+
gap: 6px;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.vc-tags span {
|
| 164 |
+
border: 1px solid rgba(103, 232, 249, .24);
|
| 165 |
+
background: rgba(8, 145, 178, .13);
|
| 166 |
+
color: #a5f3fc;
|
| 167 |
+
border-radius: 999px;
|
| 168 |
+
padding: 2px 8px;
|
| 169 |
+
font-size: 12px;
|
| 170 |
+
line-height: 18px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.vc-character-card {
|
| 174 |
+
max-height: 67vh;
|
| 175 |
+
overflow: auto;
|
| 176 |
+
padding-right: 4px;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
.vc-character-card h3 {
|
| 180 |
+
display: none;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.vc-character-card p,
|
| 184 |
+
.vc-character-card li,
|
| 185 |
+
.vc-character-card blockquote {
|
| 186 |
+
font-size: 13px;
|
| 187 |
+
line-height: 1.62;
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
.vc-character-card strong {
|
| 191 |
+
color: #fbbf24;
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
.vc-character-card blockquote {
|
| 195 |
+
border-left: 3px solid #fb7185;
|
| 196 |
+
background: rgba(251, 113, 133, .08);
|
| 197 |
+
margin: 6px 0 12px;
|
| 198 |
+
padding: 8px 10px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
#character-stage {
|
| 202 |
+
min-height: 560px;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
#character-stage > div {
|
| 206 |
+
min-height: 560px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.vc-stage2 {
|
| 210 |
+
height: min(72vh, 680px);
|
| 211 |
+
min-height: 560px;
|
| 212 |
+
position: relative;
|
| 213 |
+
overflow: hidden;
|
| 214 |
+
border-radius: 8px;
|
| 215 |
+
background:
|
| 216 |
+
radial-gradient(circle at 74% 18%, rgba(103, 232, 249, .18), transparent 32%),
|
| 217 |
+
linear-gradient(145deg, var(--bg), #09090b 74%);
|
| 218 |
+
background-size: auto, auto;
|
| 219 |
+
background-position: center, center;
|
| 220 |
+
color: #eef2ff;
|
| 221 |
+
font-family: Inter, "Microsoft YaHei", system-ui, sans-serif;
|
| 222 |
+
isolation: isolate;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
.vc-stage2::before {
|
| 226 |
+
content: "";
|
| 227 |
+
position: absolute;
|
| 228 |
+
inset: 0;
|
| 229 |
+
background:
|
| 230 |
+
linear-gradient(90deg, rgba(255,255,255,.07) 1px, transparent 1px),
|
| 231 |
+
linear-gradient(0deg, rgba(255,255,255,.05) 1px, transparent 1px);
|
| 232 |
+
background-size: 72px 72px;
|
| 233 |
+
mask-image: linear-gradient(180deg, transparent, #000 18%, #000 72%, transparent);
|
| 234 |
+
opacity: .08;
|
| 235 |
+
transform: perspective(700px) rotateX(58deg) translateY(140px) scale(1.4);
|
| 236 |
+
transform-origin: 50% 100%;
|
| 237 |
+
z-index: 1;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
.vc-stage2::after {
|
| 241 |
+
content: "";
|
| 242 |
+
position: absolute;
|
| 243 |
+
inset: auto 0 0 0;
|
| 244 |
+
height: 38%;
|
| 245 |
+
background: linear-gradient(0deg, rgba(5, 10, 18, .62), rgba(5, 10, 18, .01));
|
| 246 |
+
z-index: 1;
|
| 247 |
+
pointer-events: none;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
.vc-stage-top {
|
| 251 |
+
position: absolute;
|
| 252 |
+
z-index: 4;
|
| 253 |
+
left: 18px;
|
| 254 |
+
right: 18px;
|
| 255 |
+
top: 16px;
|
| 256 |
+
display: flex;
|
| 257 |
+
align-items: center;
|
| 258 |
+
justify-content: space-between;
|
| 259 |
+
gap: 12px;
|
| 260 |
+
font-size: 13px;
|
| 261 |
+
color: #f8fafc;
|
| 262 |
+
pointer-events: none;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
.vc-bg {
|
| 266 |
+
position: absolute;
|
| 267 |
+
z-index: 0;
|
| 268 |
+
inset: 0;
|
| 269 |
+
width: 100%;
|
| 270 |
+
height: 100%;
|
| 271 |
+
object-fit: cover;
|
| 272 |
+
opacity: .86;
|
| 273 |
+
filter: saturate(.95) brightness(.86);
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.vc-name {
|
| 277 |
+
display: inline-flex;
|
| 278 |
+
align-items: center;
|
| 279 |
+
gap: 10px;
|
| 280 |
+
font-weight: 700;
|
| 281 |
+
letter-spacing: 0;
|
| 282 |
+
min-height: 32px;
|
| 283 |
+
max-width: min(58%, 280px);
|
| 284 |
+
padding: 6px 12px;
|
| 285 |
+
border: 1px solid rgba(103, 232, 249, .42);
|
| 286 |
+
border-radius: 999px;
|
| 287 |
+
background: linear-gradient(135deg, rgba(7, 15, 25, .82), rgba(15, 23, 42, .66));
|
| 288 |
+
color: #f8fafc;
|
| 289 |
+
box-shadow: 0 10px 28px rgba(2, 6, 23, .32), inset 0 1px 0 rgba(255, 255, 255, .10);
|
| 290 |
+
text-shadow: 0 1px 8px rgba(0, 0, 0, .72);
|
| 291 |
+
backdrop-filter: blur(12px);
|
| 292 |
+
overflow: hidden;
|
| 293 |
+
text-overflow: ellipsis;
|
| 294 |
+
white-space: nowrap;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.vc-name::before {
|
| 298 |
+
content: "";
|
| 299 |
+
width: 10px;
|
| 300 |
+
height: 10px;
|
| 301 |
+
border-radius: 999px;
|
| 302 |
+
background: var(--accent);
|
| 303 |
+
box-shadow: 0 0 18px var(--accent);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.vc-status {
|
| 307 |
+
min-height: 32px;
|
| 308 |
+
max-width: min(42%, 240px);
|
| 309 |
+
padding: 6px 12px;
|
| 310 |
+
border: 1px solid rgba(251, 191, 36, .44);
|
| 311 |
+
border-radius: 999px;
|
| 312 |
+
background: linear-gradient(135deg, rgba(24, 16, 10, .82), rgba(63, 40, 12, .60));
|
| 313 |
+
color: #fff7ed;
|
| 314 |
+
box-shadow: 0 10px 28px rgba(2, 6, 23, .30), inset 0 1px 0 rgba(255, 255, 255, .10);
|
| 315 |
+
text-shadow: 0 1px 8px rgba(0, 0, 0, .74);
|
| 316 |
+
backdrop-filter: blur(12px);
|
| 317 |
+
overflow: hidden;
|
| 318 |
+
text-overflow: ellipsis;
|
| 319 |
+
white-space: nowrap;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
.vc-spotlight {
|
| 323 |
+
position: absolute;
|
| 324 |
+
z-index: 1;
|
| 325 |
+
left: 50%;
|
| 326 |
+
top: 8%;
|
| 327 |
+
width: 420px;
|
| 328 |
+
height: 460px;
|
| 329 |
+
transform: translateX(-50%);
|
| 330 |
+
border-radius: 999px;
|
| 331 |
+
background: radial-gradient(circle, rgba(103, 232, 249, .28), transparent 68%);
|
| 332 |
+
filter: blur(10px);
|
| 333 |
+
opacity: var(--talk-glow);
|
| 334 |
+
animation: vc2-pulse 3.2s ease-in-out infinite;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.vc-portrait-wrap {
|
| 338 |
+
position: absolute;
|
| 339 |
+
z-index: 3;
|
| 340 |
+
left: 50%;
|
| 341 |
+
bottom: 10px;
|
| 342 |
+
width: min(76%, 470px);
|
| 343 |
+
height: calc(100% - 66px);
|
| 344 |
+
display: flex;
|
| 345 |
+
align-items: flex-end;
|
| 346 |
+
justify-content: center;
|
| 347 |
+
transform: translateX(-50%) scale(var(--focus));
|
| 348 |
+
transform-origin: 50% 92%;
|
| 349 |
+
filter: drop-shadow(0 34px 42px rgba(0, 0, 0, .46));
|
| 350 |
+
animation: vc2-breathe 4.6s ease-in-out infinite;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.vc-portrait {
|
| 354 |
+
display: block;
|
| 355 |
+
width: auto;
|
| 356 |
+
max-width: 100%;
|
| 357 |
+
max-height: 100%;
|
| 358 |
+
height: auto;
|
| 359 |
+
object-fit: contain;
|
| 360 |
+
user-select: none;
|
| 361 |
+
pointer-events: none;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.vc-ground {
|
| 365 |
+
position: absolute;
|
| 366 |
+
z-index: 2;
|
| 367 |
+
left: 50%;
|
| 368 |
+
bottom: 12px;
|
| 369 |
+
width: 390px;
|
| 370 |
+
height: 42px;
|
| 371 |
+
transform: translateX(-50%);
|
| 372 |
+
border-radius: 999px;
|
| 373 |
+
background: radial-gradient(ellipse, rgba(0, 0, 0, .46), transparent 70%);
|
| 374 |
+
filter: blur(3px);
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
.vc-motion-talk .vc-portrait-wrap {
|
| 378 |
+
animation: vc2-talk 1.15s ease-in-out infinite;
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.vc-motion-focus .vc-portrait-wrap,
|
| 382 |
+
.vc-motion-look .vc-portrait-wrap {
|
| 383 |
+
animation: vc2-focus 2.8s ease-in-out infinite;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.vc-motion-sway .vc-portrait-wrap {
|
| 387 |
+
animation: vc2-sway 4s ease-in-out infinite;
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
.vc-motion-blink .vc-portrait-wrap {
|
| 391 |
+
animation: vc2-blink 3.4s ease-in-out infinite;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
@keyframes vc2-breathe {
|
| 395 |
+
0%, 100% { transform: translateX(-50%) translateY(0) scale(var(--focus)); }
|
| 396 |
+
50% { transform: translateX(-50%) translateY(-7px) scale(calc(var(--focus) + .012)); }
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
@keyframes vc2-talk {
|
| 400 |
+
0%, 100% { transform: translateX(-50%) translateY(0) scale(var(--focus)); filter: brightness(1); }
|
| 401 |
+
42% { transform: translateX(-50%) translateY(-8px) scale(calc(var(--focus) + .015)); filter: brightness(1.06); }
|
| 402 |
+
70% { transform: translateX(-50%) translateY(-3px) scale(calc(var(--focus) + .006)); }
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
@keyframes vc2-focus {
|
| 406 |
+
0%, 100% { transform: translateX(-50%) translateY(0) rotate(-.4deg) scale(var(--focus)); }
|
| 407 |
+
50% { transform: translateX(-50%) translateY(-6px) rotate(.7deg) scale(calc(var(--focus) + .01)); }
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
@keyframes vc2-sway {
|
| 411 |
+
0%, 100% { transform: translateX(-50%) rotate(-1deg) scale(var(--focus)); }
|
| 412 |
+
50% { transform: translateX(-50%) rotate(1.2deg) translateY(-5px) scale(calc(var(--focus) + .01)); }
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
@keyframes vc2-blink {
|
| 416 |
+
0%, 100% { transform: translateX(-50%) translateY(0) scale(var(--focus)); opacity: 1; }
|
| 417 |
+
48% { transform: translateX(-50%) translateY(-4px) scale(var(--focus)); opacity: .98; }
|
| 418 |
+
52% { transform: translateX(-50%) translateY(-4px) scale(1.006); opacity: .94; }
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
@keyframes vc2-pulse {
|
| 422 |
+
0%, 100% { opacity: var(--talk-glow); transform: translateX(-50%) scale(.98); }
|
| 423 |
+
50% { opacity: calc(var(--talk-glow) + .08); transform: translateX(-50%) scale(1.04); }
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.vc-output #component-0,
|
| 427 |
+
.vc-output textarea,
|
| 428 |
+
.vc-output .wrap {
|
| 429 |
+
background: rgba(9, 9, 11, .42);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.vc-model-grid {
|
| 433 |
+
display: grid;
|
| 434 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 435 |
+
gap: 10px;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.vc-workshop-grid {
|
| 439 |
+
display: grid;
|
| 440 |
+
gap: 12px;
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
.vc-workshop-status p {
|
| 444 |
+
margin: 0;
|
| 445 |
+
color: rgba(228, 228, 231, .78);
|
| 446 |
+
font-size: 13px;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.vc-workshop-shell {
|
| 450 |
+
position: relative;
|
| 451 |
+
isolation: isolate;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.gradio-container .wrap.full,
|
| 455 |
+
.gradio-container .wrap.minimal,
|
| 456 |
+
.gradio-container .wrap.center.full,
|
| 457 |
+
.gradio-container .wrap.default.full,
|
| 458 |
+
.gradio-container .wrap.default.minimal {
|
| 459 |
+
opacity: 0 !important;
|
| 460 |
+
background: transparent !important;
|
| 461 |
+
pointer-events: none !important;
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
.vc-model-pill {
|
| 465 |
+
min-height: 84px;
|
| 466 |
+
border: 1px solid rgba(212, 212, 216, .14);
|
| 467 |
+
border-radius: 8px;
|
| 468 |
+
background: rgba(24, 24, 27, .72);
|
| 469 |
+
padding: 10px 12px;
|
| 470 |
+
display: grid;
|
| 471 |
+
gap: 4px;
|
| 472 |
+
position: relative;
|
| 473 |
+
overflow: hidden;
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
.vc-model-pill::before {
|
| 477 |
+
content: "";
|
| 478 |
+
position: absolute;
|
| 479 |
+
left: 0;
|
| 480 |
+
top: 0;
|
| 481 |
+
bottom: 0;
|
| 482 |
+
width: 4px;
|
| 483 |
+
background: #71717a;
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
.vc-model-pill b {
|
| 487 |
+
font-size: 13px;
|
| 488 |
+
color: #f4f4f5;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.vc-model-pill span {
|
| 492 |
+
font-size: 13px;
|
| 493 |
+
color: #e4e4e7;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
.vc-model-pill small {
|
| 497 |
+
font-size: 11px;
|
| 498 |
+
color: rgba(212, 212, 216, .62);
|
| 499 |
+
white-space: nowrap;
|
| 500 |
+
overflow: hidden;
|
| 501 |
+
text-overflow: ellipsis;
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
.vc-model-pill em {
|
| 505 |
+
font-style: normal;
|
| 506 |
+
font-size: 12px;
|
| 507 |
+
color: rgba(228, 228, 231, .74);
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.vc-model-ready::before {
|
| 511 |
+
background: #22c55e;
|
| 512 |
+
box-shadow: 0 0 16px rgba(34, 197, 94, .46);
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
.vc-model-loading::before,
|
| 516 |
+
.vc-model-sleeping::before,
|
| 517 |
+
.vc-model-unknown::before {
|
| 518 |
+
background: #f59e0b;
|
| 519 |
+
box-shadow: 0 0 16px rgba(245, 158, 11, .42);
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.vc-model-error::before {
|
| 523 |
+
background: #fb7185;
|
| 524 |
+
box-shadow: 0 0 16px rgba(251, 113, 133, .42);
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
.vc-model-local::before {
|
| 528 |
+
background: #67e8f9;
|
| 529 |
+
box-shadow: 0 0 16px rgba(103, 232, 249, .42);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.vc-model-unconfigured::before {
|
| 533 |
+
background: #71717a;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
.vc-input-dock {
|
| 537 |
+
margin-top: 12px;
|
| 538 |
+
border: 1px solid rgba(103, 232, 249, .20);
|
| 539 |
+
border-radius: 8px;
|
| 540 |
+
background: rgba(229, 231, 235, .96);
|
| 541 |
+
box-shadow: 0 -18px 60px rgba(0, 0, 0, .28);
|
| 542 |
+
padding: 12px;
|
| 543 |
+
color: #111827;
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
#vc-message {
|
| 547 |
+
border-radius: 8px;
|
| 548 |
+
background: #f8fafc !important;
|
| 549 |
+
color: #111827 !important;
|
| 550 |
+
border: 1px solid rgba(8, 145, 178, .28);
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
#vc-message,
|
| 554 |
+
#vc-message * {
|
| 555 |
+
color: #111827 !important;
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
#vc-message textarea,
|
| 559 |
+
#vc-message input,
|
| 560 |
+
#vc-message [contenteditable="true"] {
|
| 561 |
+
background: #f8fafc !important;
|
| 562 |
+
color: #111827 !important;
|
| 563 |
+
caret-color: #0891b2 !important;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
#vc-message textarea::placeholder,
|
| 567 |
+
#vc-message input::placeholder {
|
| 568 |
+
color: #64748b !important;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
#vc-message button {
|
| 572 |
+
background: rgba(8, 145, 178, .10) !important;
|
| 573 |
+
border-color: rgba(8, 145, 178, .24) !important;
|
| 574 |
+
color: #0f172a !important;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
#vc-message svg {
|
| 578 |
+
color: #0f172a !important;
|
| 579 |
+
stroke: currentColor !important;
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
#voice-output {
|
| 583 |
+
min-height: 72px;
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
.vc-audio-status p {
|
| 587 |
+
margin: 0;
|
| 588 |
+
color: rgba(228, 228, 231, .76);
|
| 589 |
+
font-size: 13px;
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
@media (max-width: 980px) {
|
| 593 |
+
.vc-stage-col {
|
| 594 |
+
order: 1;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
.vc-left {
|
| 598 |
+
order: 2;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.vc-right {
|
| 602 |
+
order: 3;
|
| 603 |
+
}
|
| 604 |
+
|
| 605 |
+
.vc-status-wrap,
|
| 606 |
+
.vc-status-actions {
|
| 607 |
+
min-width: 100% !important;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.vc-model-grid {
|
| 611 |
+
grid-template-columns: 1fr;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
.vc-character-card {
|
| 615 |
+
max-height: none;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
#character-stage,
|
| 619 |
+
#character-stage > div {
|
| 620 |
+
min-height: 480px;
|
| 621 |
+
}
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
.gradio-container {
|
| 625 |
+
color-scheme: light;
|
| 626 |
+
--vc-page-text: #172033;
|
| 627 |
+
--vc-page-muted: #526173;
|
| 628 |
+
--vc-page-bg:
|
| 629 |
+
radial-gradient(circle at 10% 8%, rgba(103, 232, 249, .22), transparent 28%),
|
| 630 |
+
radial-gradient(circle at 82% 0%, rgba(251, 191, 36, .20), transparent 26%),
|
| 631 |
+
linear-gradient(135deg, #eef7f8 0%, #f8f4ec 46%, #f4eef2 100%);
|
| 632 |
+
--vc-panel-bg: rgba(255, 255, 255, .86);
|
| 633 |
+
--vc-panel-border: rgba(15, 23, 42, .13);
|
| 634 |
+
--vc-panel-shadow: 0 18px 45px rgba(30, 41, 59, .14);
|
| 635 |
+
--vc-card-strong: #a16207;
|
| 636 |
+
--vc-tag-bg: rgba(8, 145, 178, .10);
|
| 637 |
+
--vc-tag-text: #0e7490;
|
| 638 |
+
--vc-pill-bg: rgba(255, 255, 255, .84);
|
| 639 |
+
--vc-pill-text: #172033;
|
| 640 |
+
--vc-pill-muted: #64748b;
|
| 641 |
+
--vc-input-bg: rgba(255, 255, 255, .92);
|
| 642 |
+
--vc-input-text: #172033;
|
| 643 |
+
--vc-input-muted: #526173;
|
| 644 |
+
--vc-chat-bg: rgba(255, 255, 255, .78);
|
| 645 |
+
background: var(--vc-page-bg) !important;
|
| 646 |
+
color: var(--vc-page-text) !important;
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
.gradio-container:has(#vc-mode-night) {
|
| 650 |
+
color-scheme: dark;
|
| 651 |
+
--vc-page-text: #e5e7eb;
|
| 652 |
+
--vc-page-muted: rgba(228, 228, 231, .72);
|
| 653 |
+
--vc-page-bg:
|
| 654 |
+
radial-gradient(circle at 12% 14%, rgba(103, 232, 249, .12), transparent 30%),
|
| 655 |
+
radial-gradient(circle at 82% 8%, rgba(251, 191, 36, .10), transparent 28%),
|
| 656 |
+
linear-gradient(135deg, rgba(8, 11, 16, .98), rgba(15, 16, 18, .98) 48%, rgba(20, 12, 17, .98));
|
| 657 |
+
--vc-panel-bg: rgba(18, 20, 24, .82);
|
| 658 |
+
--vc-panel-border: rgba(212, 212, 216, .13);
|
| 659 |
+
--vc-panel-shadow: 0 18px 52px rgba(0, 0, 0, .24);
|
| 660 |
+
--vc-card-strong: #fbbf24;
|
| 661 |
+
--vc-tag-bg: rgba(8, 145, 178, .13);
|
| 662 |
+
--vc-tag-text: #a5f3fc;
|
| 663 |
+
--vc-pill-bg: rgba(24, 24, 27, .72);
|
| 664 |
+
--vc-pill-text: #f4f4f5;
|
| 665 |
+
--vc-pill-muted: rgba(212, 212, 216, .62);
|
| 666 |
+
--vc-input-bg: rgba(229, 231, 235, .96);
|
| 667 |
+
--vc-input-text: #111827;
|
| 668 |
+
--vc-input-muted: #475569;
|
| 669 |
+
--vc-chat-bg: rgba(9, 9, 11, .42);
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.vc-panel {
|
| 673 |
+
background: var(--vc-panel-bg) !important;
|
| 674 |
+
border-color: var(--vc-panel-border) !important;
|
| 675 |
+
box-shadow: var(--vc-panel-shadow) !important;
|
| 676 |
+
color: var(--vc-page-text) !important;
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
.vc-input-dock {
|
| 680 |
+
background: var(--vc-input-bg) !important;
|
| 681 |
+
border-color: var(--vc-panel-border) !important;
|
| 682 |
+
box-shadow: var(--vc-panel-shadow) !important;
|
| 683 |
+
color: var(--vc-input-text) !important;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
.vc-panel *,
|
| 687 |
+
.vc-character-card,
|
| 688 |
+
.vc-character-card p,
|
| 689 |
+
.vc-character-card li,
|
| 690 |
+
.vc-character-card blockquote,
|
| 691 |
+
.vc-card-title small {
|
| 692 |
+
color: var(--vc-page-text);
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
.vc-panel .label-wrap {
|
| 696 |
+
color: var(--vc-page-muted) !important;
|
| 697 |
+
}
|
| 698 |
+
|
| 699 |
+
.vc-input-dock .label-wrap {
|
| 700 |
+
color: var(--vc-input-muted) !important;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
.vc-card-head {
|
| 704 |
+
border-bottom-color: var(--vc-panel-border) !important;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.vc-card-title strong {
|
| 708 |
+
color: var(--vc-page-text) !important;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
.vc-tags span {
|
| 712 |
+
background: var(--vc-tag-bg) !important;
|
| 713 |
+
color: var(--vc-tag-text) !important;
|
| 714 |
+
border-color: rgba(8, 145, 178, .22) !important;
|
| 715 |
+
}
|
| 716 |
+
|
| 717 |
+
.vc-character-card strong {
|
| 718 |
+
color: var(--vc-card-strong) !important;
|
| 719 |
+
}
|
| 720 |
+
|
| 721 |
+
.vc-character-card blockquote {
|
| 722 |
+
color: var(--vc-page-text) !important;
|
| 723 |
+
background: rgba(251, 113, 133, .09) !important;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.vc-model-pill {
|
| 727 |
+
background: var(--vc-pill-bg) !important;
|
| 728 |
+
border-color: var(--vc-panel-border) !important;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
.vc-model-pill b,
|
| 732 |
+
.vc-model-pill span {
|
| 733 |
+
color: var(--vc-pill-text) !important;
|
| 734 |
+
}
|
| 735 |
+
|
| 736 |
+
.vc-model-pill small,
|
| 737 |
+
.vc-model-pill em {
|
| 738 |
+
color: var(--vc-pill-muted) !important;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.vc-output textarea,
|
| 742 |
+
.vc-output .wrap,
|
| 743 |
+
.vc-output [data-testid="chatbot"] {
|
| 744 |
+
background: var(--vc-chat-bg) !important;
|
| 745 |
+
color: var(--vc-page-text) !important;
|
| 746 |
+
}
|
| 747 |
+
|
| 748 |
+
.vc-audio-status p {
|
| 749 |
+
color: var(--vc-page-muted) !important;
|
| 750 |
+
}
|
| 751 |
+
|
| 752 |
+
.vc-input-dock {
|
| 753 |
+
background: var(--vc-input-bg) !important;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
.gradio-container:not(:has(#vc-mode-night)) .vc-stage2 {
|
| 757 |
+
box-shadow: 0 24px 64px rgba(15, 23, 42, .20);
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
.gradio-container:not(:has(#vc-mode-night)) .vc-bg {
|
| 761 |
+
opacity: .94;
|
| 762 |
+
filter: saturate(1.03) brightness(1.04);
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
.gradio-container:not(:has(#vc-mode-night)) .vc-stage2::after {
|
| 766 |
+
background: linear-gradient(0deg, rgba(15, 23, 42, .36), rgba(15, 23, 42, .01));
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.vc-appearance-mode label {
|
| 770 |
+
color: var(--vc-page-text) !important;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.gradio-container:not(:has(#vc-mode-night)) .vc-appearance-mode,
|
| 774 |
+
.gradio-container:not(:has(#vc-mode-night)) .vc-appearance-mode *,
|
| 775 |
+
.gradio-container:not(:has(#vc-mode-night)) input,
|
| 776 |
+
.gradio-container:not(:has(#vc-mode-night)) textarea,
|
| 777 |
+
.gradio-container:not(:has(#vc-mode-night)) select,
|
| 778 |
+
.gradio-container:not(:has(#vc-mode-night)) button {
|
| 779 |
+
color: #172033 !important;
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.gradio-container:not(:has(#vc-mode-night)) input,
|
| 783 |
+
.gradio-container:not(:has(#vc-mode-night)) textarea,
|
| 784 |
+
.gradio-container:not(:has(#vc-mode-night)) select,
|
| 785 |
+
.gradio-container:not(:has(#vc-mode-night)) .wrap,
|
| 786 |
+
.gradio-container:not(:has(#vc-mode-night)) .container {
|
| 787 |
+
background-color: rgba(255, 255, 255, .88) !important;
|
| 788 |
+
border-color: rgba(15, 23, 42, .12) !important;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.gradio-container:not(:has(#vc-mode-night)) button {
|
| 792 |
+
background: rgba(255, 255, 255, .82) !important;
|
| 793 |
+
border-color: rgba(8, 145, 178, .22) !important;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
.gradio-container:not(:has(#vc-mode-night)) button.primary,
|
| 797 |
+
.gradio-container:not(:has(#vc-mode-night)) #vc-message button:last-child {
|
| 798 |
+
background: linear-gradient(135deg, #0891b2, #0f766e) !important;
|
| 799 |
+
color: #ffffff !important;
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
.gradio-container:has(#vc-mode-night) .vc-appearance-mode,
|
| 803 |
+
.gradio-container:has(#vc-mode-night) .vc-appearance-mode *,
|
| 804 |
+
.gradio-container:has(#vc-mode-night) .vc-panel input,
|
| 805 |
+
.gradio-container:has(#vc-mode-night) .vc-panel textarea,
|
| 806 |
+
.gradio-container:has(#vc-mode-night) .vc-panel select {
|
| 807 |
+
color: #e5e7eb !important;
|
| 808 |
+
}
|
| 809 |
+
"""
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
VOICE_STYLE_CHOICES = [
|
| 813 |
+
("跟随角色", "neutral"),
|
| 814 |
+
("温柔", "soft"),
|
| 815 |
+
("坚定", "firm"),
|
| 816 |
+
("开心", "happy"),
|
| 817 |
+
("担心", "concerned"),
|
| 818 |
+
("俏皮", "playful"),
|
| 819 |
+
]
|
| 820 |
+
|
| 821 |
+
APPEARANCE_CHOICES = [("晨光舱", "day"), ("夜航舱", "night")]
|
| 822 |
+
|
| 823 |
+
|
| 824 |
+
def _theme() -> gr.Theme:
|
| 825 |
+
return gr.themes.Default(primary_hue="cyan", secondary_hue="amber", neutral_hue="zinc")
|
| 826 |
+
|
| 827 |
+
|
| 828 |
+
def _appearance_marker(mode: str) -> str:
|
| 829 |
+
marker_id = "vc-mode-night" if mode == "night" else "vc-mode-day"
|
| 830 |
+
return f'<span id="{marker_id}" aria-hidden="true" style="display:none"></span>'
|
| 831 |
+
|
| 832 |
+
|
| 833 |
+
def _character_choices() -> list[tuple[str, str]]:
|
| 834 |
+
return [
|
| 835 |
+
(character_display_name(character), character_id)
|
| 836 |
+
for character_id, character in get_character_packages().items()
|
| 837 |
+
]
|
| 838 |
+
|
| 839 |
+
|
| 840 |
+
def _voice_choices(character: dict) -> list[tuple[str, str]]:
|
| 841 |
+
choices = character.get("voice_options") or []
|
| 842 |
+
if choices:
|
| 843 |
+
return [(str(label), str(value)) for label, value in choices]
|
| 844 |
+
voice = character.get("voice", {})
|
| 845 |
+
return [(voice.get("voice_label") or voice.get("voice_id") or "默认音色", voice.get("voice_id") or "default")]
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
def _initial_state(character_id: str) -> dict:
|
| 849 |
+
character = get_character(character_id)
|
| 850 |
+
return {
|
| 851 |
+
"character_id": character_id,
|
| 852 |
+
"stage": {"expression": "idle", "motion": "breathe", "intensity": 0.35},
|
| 853 |
+
"events": [],
|
| 854 |
+
"last_vision_note": None,
|
| 855 |
+
"character": character,
|
| 856 |
+
"voice": _default_voice_state(character, enabled=True),
|
| 857 |
+
}
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
def _default_voice_state(character: dict, enabled: bool) -> dict[str, Any]:
|
| 861 |
+
voice = character.get("voice", {})
|
| 862 |
+
return {
|
| 863 |
+
"enabled": enabled,
|
| 864 |
+
"voice_id": voice.get("voice_id", "default"),
|
| 865 |
+
"style": voice.get("default_style", "neutral"),
|
| 866 |
+
"emotion": voice.get("default_style", "neutral"),
|
| 867 |
+
"speed": _pace_to_speed(voice.get("pace", "normal")),
|
| 868 |
+
"energy": float(voice.get("energy", 0.5)),
|
| 869 |
+
"audio_prompt_path": voice.get("audio_prompt_path"),
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
|
| 873 |
+
def _pace_to_speed(pace: str) -> float:
|
| 874 |
+
return {"slow": 0.92, "normal": 1.0, "fast": 1.08}.get(str(pace), 1.0)
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def _opening_history(character: dict) -> list[dict[str, str]]:
|
| 878 |
+
first_mes = str(character.get("first_mes") or "").strip()
|
| 879 |
+
if not first_mes:
|
| 880 |
+
return []
|
| 881 |
+
return [{"role": "assistant", "content": first_mes}]
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
def _history_for_model(history: list[dict], character: dict) -> list[dict]:
|
| 885 |
+
history = list(history or [])
|
| 886 |
+
first_mes = str(character.get("first_mes") or "").strip()
|
| 887 |
+
if history and history[0].get("role") == "assistant" and str(history[0].get("content") or "").strip() == first_mes:
|
| 888 |
+
return history[1:]
|
| 889 |
+
return history
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
def _character_header_html(character: dict) -> str:
|
| 893 |
+
tags = "".join(f"<span>{html.escape(str(tag))}</span>" for tag in character.get("tags", [])[:6])
|
| 894 |
+
avatar_uri = _avatar_uri(character)
|
| 895 |
+
name = html.escape(character_display_name(character))
|
| 896 |
+
summary = html.escape(character.get("summary", ""))
|
| 897 |
+
return f"""
|
| 898 |
+
<div class="vc-card-head">
|
| 899 |
+
<img src="{avatar_uri}" alt="{name}" />
|
| 900 |
+
<div class="vc-card-title">
|
| 901 |
+
<strong>{name}</strong>
|
| 902 |
+
<div class="vc-tags">{tags}</div>
|
| 903 |
+
<small>{summary}</small>
|
| 904 |
+
</div>
|
| 905 |
+
</div>
|
| 906 |
+
"""
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
def _avatar_uri(character: dict) -> str:
|
| 910 |
+
avatar = character.get("visual", {}).get("avatar", "star")
|
| 911 |
+
for candidate in (
|
| 912 |
+
ROOT / "assets" / "characters" / avatar / "idle.png",
|
| 913 |
+
ROOT / "assets" / "characters" / "star" / "idle.png",
|
| 914 |
+
):
|
| 915 |
+
if candidate.exists():
|
| 916 |
+
encoded = base64.b64encode(candidate.read_bytes()).decode("ascii")
|
| 917 |
+
return f"data:image/png;base64,{encoded}"
|
| 918 |
+
return ""
|
| 919 |
+
|
| 920 |
+
|
| 921 |
+
def switch_character(character_id: str):
|
| 922 |
+
state = _initial_state(character_id)
|
| 923 |
+
character = state["character"]
|
| 924 |
+
voice = state["voice"]
|
| 925 |
+
return (
|
| 926 |
+
_character_header_html(character),
|
| 927 |
+
format_character_card_markdown(character),
|
| 928 |
+
render_character_stage(character, state["stage"]),
|
| 929 |
+
state,
|
| 930 |
+
_opening_history(character),
|
| 931 |
+
{"events": []},
|
| 932 |
+
gr.update(choices=_voice_choices(character), value=voice["voice_id"]),
|
| 933 |
+
gr.update(value=voice["style"]),
|
| 934 |
+
gr.update(value=voice["speed"]),
|
| 935 |
+
gr.update(value=voice["energy"]),
|
| 936 |
+
gr.update(value=True),
|
| 937 |
+
"等待新的语音回复。",
|
| 938 |
+
None,
|
| 939 |
+
)
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
def refresh_model_status():
|
| 943 |
+
statuses = check_all_statuses()
|
| 944 |
+
return statuses_markdown(statuses), statuses_json(statuses)
|
| 945 |
+
|
| 946 |
+
|
| 947 |
+
def refresh_model_status_both():
|
| 948 |
+
statuses = check_all_statuses()
|
| 949 |
+
html_status = statuses_markdown(statuses)
|
| 950 |
+
note = _model_action_note(statuses)
|
| 951 |
+
return html_status, html_status, statuses_json(statuses), note, note
|
| 952 |
+
|
| 953 |
+
|
| 954 |
+
def refresh_workshop_status_only():
|
| 955 |
+
return statuses_markdown(check_all_statuses())
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
def start_main_model():
|
| 959 |
+
starting_status = llm_loading_status()
|
| 960 |
+
statuses = statuses_with_llm_status(starting_status)
|
| 961 |
+
html_status = statuses_markdown(statuses)
|
| 962 |
+
note = "主模型启动请求已发出。首次加载可能需要 1-3 分钟;这个操作只预热当前服务,不会把 GPU 常驻。"
|
| 963 |
+
yield html_status, html_status, statuses_json(statuses), note, note
|
| 964 |
+
|
| 965 |
+
result = warm_llm_model()
|
| 966 |
+
statuses = statuses_with_llm_status(result)
|
| 967 |
+
html_status = statuses_markdown(statuses)
|
| 968 |
+
note = _model_action_note(statuses, warmup=True)
|
| 969 |
+
yield html_status, html_status, statuses_json(statuses), note, note
|
| 970 |
+
|
| 971 |
+
|
| 972 |
+
def _initial_model_action_note() -> str:
|
| 973 |
+
return "主模型按需启动。首次对话前可先启动模型;启动完成后几分钟内对话会更快。"
|
| 974 |
+
|
| 975 |
+
|
| 976 |
+
def _model_action_note(statuses: list, *, warmup: bool = False) -> str:
|
| 977 |
+
llm = next((status for status in statuses if status.kind == "llm"), None)
|
| 978 |
+
if not llm:
|
| 979 |
+
return _initial_model_action_note()
|
| 980 |
+
if llm.state == "ready":
|
| 981 |
+
prefix = "主模型已启动。" if warmup else "主模型可用。"
|
| 982 |
+
return f"{prefix} 当前请求延迟约 {llm.latency_s:.1f}s。" if llm.latency_s is not None else prefix
|
| 983 |
+
if llm.state == "loading":
|
| 984 |
+
return llm.message or "主模型正在启动;稍后刷新状态。"
|
| 985 |
+
if llm.state == "sleeping":
|
| 986 |
+
return "主模型已休眠;可以点击启动主模型,或直接发送消息等待冷启动。"
|
| 987 |
+
if llm.state == "mock":
|
| 988 |
+
return "当前使用本地 mock,对话不会等待 Modal 模型。"
|
| 989 |
+
if llm.state == "unconfigured":
|
| 990 |
+
return "主模型 endpoint 未配置。"
|
| 991 |
+
return llm.message or "主模型状态异常,请刷新状态或检查 endpoint。"
|
| 992 |
+
|
| 993 |
+
|
| 994 |
+
def _hf_oauth_available() -> bool:
|
| 995 |
+
if os.environ.get("SPACE_ID") or os.environ.get("HF_TOKEN"):
|
| 996 |
+
return True
|
| 997 |
+
try:
|
| 998 |
+
from huggingface_hub import get_token
|
| 999 |
+
|
| 1000 |
+
return bool(get_token())
|
| 1001 |
+
except Exception:
|
| 1002 |
+
return False
|
| 1003 |
+
|
| 1004 |
+
|
| 1005 |
+
def workshop_login_status(profile: gr.OAuthProfile | None = None) -> str:
|
| 1006 |
+
user = get_current_user(profile)
|
| 1007 |
+
if user.authenticated:
|
| 1008 |
+
return f"已登录 Hugging Face:{user.display_name}(@{user.username})。生成进度会保存到你的任务列表。"
|
| 1009 |
+
return "未登录。可以先填写或导入角色;点击生成、打包、安装前需要使用 Hugging Face 登录。"
|
| 1010 |
+
|
| 1011 |
+
|
| 1012 |
+
def workshop_refresh_runs(profile: gr.OAuthProfile | None = None):
|
| 1013 |
+
user = get_current_user(profile)
|
| 1014 |
+
choices = list_user_workshop_runs(user)
|
| 1015 |
+
value = choices[0][1] if choices else None
|
| 1016 |
+
return (
|
| 1017 |
+
workshop_login_status(profile),
|
| 1018 |
+
gr.update(choices=choices, value=value),
|
| 1019 |
+
summarize_workshop_stats(),
|
| 1020 |
+
)
|
| 1021 |
+
|
| 1022 |
+
|
| 1023 |
+
def workshop_load_selected_run(run_dir: str | None, profile: gr.OAuthProfile | None = None):
|
| 1024 |
+
try:
|
| 1025 |
+
user = get_current_user(profile)
|
| 1026 |
+
choices = list_user_workshop_runs(user)
|
| 1027 |
+
selected = run_dir or (choices[0][1] if choices else None)
|
| 1028 |
+
if not selected:
|
| 1029 |
+
return (*_empty_workshop_outputs("没有可恢复的角色生成任务。"), gr.update(choices=choices, value=None), summarize_workshop_stats())
|
| 1030 |
+
state = load_workshop_run(selected, user=user)
|
| 1031 |
+
return (*_workshop_state_outputs(state, "已加载历史任务,可以从中断位置继续。"), gr.update(choices=choices, value=selected), summarize_workshop_stats())
|
| 1032 |
+
except Exception as exc:
|
| 1033 |
+
user = get_current_user(profile)
|
| 1034 |
+
return (*_empty_workshop_outputs(f"加载任务失败:{exc}"), gr.update(choices=list_user_workshop_runs(user), value=run_dir), summarize_workshop_stats())
|
| 1035 |
+
|
| 1036 |
+
|
| 1037 |
+
def workshop_load_recent_run(profile: gr.OAuthProfile | None = None):
|
| 1038 |
+
return workshop_load_selected_run(None, profile)
|
| 1039 |
+
|
| 1040 |
+
|
| 1041 |
+
def _workshop_state_outputs(state: dict, message: str):
|
| 1042 |
+
draft = state.get("draft") or {}
|
| 1043 |
+
expression_paths = [
|
| 1044 |
+
state.get("expression_assets", {}).get(slot)
|
| 1045 |
+
for slot in ("idle", "listening", "thinking", "worried", "smile", "happy", "talk", "focus")
|
| 1046 |
+
if state.get("expression_assets", {}).get(slot)
|
| 1047 |
+
]
|
| 1048 |
+
package_dir = Path(state.get("package_dir") or Path(state.get("run_dir") or "") / "package")
|
| 1049 |
+
grid_path = package_dir / "generated" / "asset_grid.png"
|
| 1050 |
+
preview_html = ""
|
| 1051 |
+
if grid_path.exists():
|
| 1052 |
+
try:
|
| 1053 |
+
preview_html = render_packaged_stage_preview(state)
|
| 1054 |
+
except Exception:
|
| 1055 |
+
preview_html = ""
|
| 1056 |
+
selected = state.get("selected_candidate_index")
|
| 1057 |
+
selected_label = f"当前选择:{selected}" if selected is not None else "当前选择:无。"
|
| 1058 |
+
return (
|
| 1059 |
+
message,
|
| 1060 |
+
format_character_card_markdown(draft) if draft else "",
|
| 1061 |
+
state,
|
| 1062 |
+
state.get("main_candidates") or [],
|
| 1063 |
+
selected if selected is not None else 0,
|
| 1064 |
+
selected_label,
|
| 1065 |
+
expression_paths,
|
| 1066 |
+
state.get("background_asset"),
|
| 1067 |
+
str(grid_path) if grid_path.exists() else None,
|
| 1068 |
+
preview_html,
|
| 1069 |
+
)
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
def _empty_workshop_outputs(message: str):
|
| 1073 |
+
return (message, "", {}, [], 0, "当前选择:无。", [], None, None, "")
|
| 1074 |
+
|
| 1075 |
+
|
| 1076 |
+
def _workshop_run_dropdown_update(profile: gr.OAuthProfile | None, state: dict | None = None):
|
| 1077 |
+
user = get_current_user(profile)
|
| 1078 |
+
choices = list_user_workshop_runs(user)
|
| 1079 |
+
value = (state or {}).get("run_dir")
|
| 1080 |
+
if not value and choices:
|
| 1081 |
+
value = choices[0][1]
|
| 1082 |
+
return gr.update(choices=choices, value=value)
|
| 1083 |
+
|
| 1084 |
+
|
| 1085 |
+
def workshop_create_from_form(
|
| 1086 |
+
display_name: str,
|
| 1087 |
+
description: str,
|
| 1088 |
+
personality: str,
|
| 1089 |
+
scenario: str,
|
| 1090 |
+
first_mes: str,
|
| 1091 |
+
tags: str,
|
| 1092 |
+
):
|
| 1093 |
+
try:
|
| 1094 |
+
draft = create_draft_from_form(
|
| 1095 |
+
display_name=display_name,
|
| 1096 |
+
description=description,
|
| 1097 |
+
personality=personality,
|
| 1098 |
+
scenario=scenario,
|
| 1099 |
+
first_mes=first_mes,
|
| 1100 |
+
tags=tags,
|
| 1101 |
+
)
|
| 1102 |
+
workshop_state = create_initial_state(draft, persist=False)
|
| 1103 |
+
return (
|
| 1104 |
+
"草案已创建,可以生成主视觉候选。",
|
| 1105 |
+
format_character_card_markdown(draft),
|
| 1106 |
+
workshop_state,
|
| 1107 |
+
[],
|
| 1108 |
+
0,
|
| 1109 |
+
"当前选择:尚未生成候选。",
|
| 1110 |
+
[],
|
| 1111 |
+
None,
|
| 1112 |
+
None,
|
| 1113 |
+
"",
|
| 1114 |
+
)
|
| 1115 |
+
except Exception as exc:
|
| 1116 |
+
return (f"创建草案失败:{exc}", "", {}, [], 0, "当前选择:无。", [], None, None, "")
|
| 1117 |
+
|
| 1118 |
+
|
| 1119 |
+
def workshop_import_tavern(file):
|
| 1120 |
+
try:
|
| 1121 |
+
draft = create_draft_from_tavern_json(file)
|
| 1122 |
+
workshop_state = create_initial_state(draft, persist=False)
|
| 1123 |
+
return (
|
| 1124 |
+
"Tavern JSON 已导入,可以生成主视觉候选。",
|
| 1125 |
+
format_character_card_markdown(draft),
|
| 1126 |
+
workshop_state,
|
| 1127 |
+
[],
|
| 1128 |
+
0,
|
| 1129 |
+
"当前选择:尚未生成候选。",
|
| 1130 |
+
[],
|
| 1131 |
+
None,
|
| 1132 |
+
None,
|
| 1133 |
+
"",
|
| 1134 |
+
)
|
| 1135 |
+
except Exception as exc:
|
| 1136 |
+
return (f"导入失败:{exc}", "", {}, [], 0, "当前选择:无。", [], None, None, "")
|
| 1137 |
+
|
| 1138 |
+
|
| 1139 |
+
def _require_workshop_model_ready() -> str | None:
|
| 1140 |
+
status = check_image_generation_status()
|
| 1141 |
+
if status.state == "ready":
|
| 1142 |
+
return None
|
| 1143 |
+
return status.message or "Modal 图像生成服务可能已休眠或正在冷启动,请等待容器启动和模型载入后重试。"
|
| 1144 |
+
|
| 1145 |
+
|
| 1146 |
+
def workshop_generate_main_candidates(workshop_state: dict | None, profile: gr.OAuthProfile | None = None):
|
| 1147 |
+
started = time.perf_counter()
|
| 1148 |
+
user = get_current_user(profile)
|
| 1149 |
+
try:
|
| 1150 |
+
user = require_login_for_generation(profile)
|
| 1151 |
+
except ValueError as exc:
|
| 1152 |
+
record_workshop_event(user, "generate_main_candidates", {"stage": "auth_required", "success": False, "failure_reason": str(exc)})
|
| 1153 |
+
return str(exc), gr.update(), workshop_state or {}, "当前选择:无。", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1154 |
+
wait_message = _require_workshop_model_ready()
|
| 1155 |
+
if wait_message:
|
| 1156 |
+
record_workshop_event(user, "generate_main_candidates", {"stage": "modal_wait", "success": False, "failure_reason": wait_message, "modal_state": "not_ready"})
|
| 1157 |
+
return wait_message, gr.update(), workshop_state or {}, "当前选择:无。", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1158 |
+
try:
|
| 1159 |
+
workshop_state = ensure_user_workshop_run(workshop_state or {}, user)
|
| 1160 |
+
workshop_state = generate_main_candidates(workshop_state)
|
| 1161 |
+
paths = workshop_state.get("main_candidates") or []
|
| 1162 |
+
record_workshop_event(
|
| 1163 |
+
user,
|
| 1164 |
+
"generate_main_candidates",
|
| 1165 |
+
{
|
| 1166 |
+
"stage": "main_candidates",
|
| 1167 |
+
"character_id": workshop_state.get("character_id"),
|
| 1168 |
+
"duration_seconds": round(time.perf_counter() - started, 3),
|
| 1169 |
+
"success": True,
|
| 1170 |
+
"image_count": len(paths),
|
| 1171 |
+
},
|
| 1172 |
+
)
|
| 1173 |
+
return "主视觉候选已生成。请选择其中一张。", paths, workshop_state, "当前选择:0", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1174 |
+
except Exception as exc:
|
| 1175 |
+
record_workshop_event(user, "generate_main_candidates", {"stage": "main_candidates", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)})
|
| 1176 |
+
return f"主视觉生成失败:{exc}", gr.update(), workshop_state or {}, "当前选择:无。", _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1177 |
+
|
| 1178 |
+
|
| 1179 |
+
def workshop_select_candidate(workshop_state: dict | None, evt: gr.SelectData):
|
| 1180 |
+
try:
|
| 1181 |
+
index = int(evt.index if evt is not None else 0)
|
| 1182 |
+
workshop_state = select_main_candidate(workshop_state or {}, index)
|
| 1183 |
+
return workshop_state, f"当前选择:{index}"
|
| 1184 |
+
except Exception as exc:
|
| 1185 |
+
return workshop_state or {}, f"选择失败:{exc}"
|
| 1186 |
+
|
| 1187 |
+
|
| 1188 |
+
def workshop_generate_assets(workshop_state: dict | None, profile: gr.OAuthProfile | None = None):
|
| 1189 |
+
started = time.perf_counter()
|
| 1190 |
+
user = get_current_user(profile)
|
| 1191 |
+
try:
|
| 1192 |
+
user = require_login_for_generation(profile)
|
| 1193 |
+
except ValueError as exc:
|
| 1194 |
+
record_workshop_event(user, "generate_expression_pack", {"stage": "auth_required", "success": False, "failure_reason": str(exc)})
|
| 1195 |
+
return str(exc), gr.update(), None, workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1196 |
+
wait_message = _require_workshop_model_ready()
|
| 1197 |
+
if wait_message:
|
| 1198 |
+
record_workshop_event(user, "generate_expression_pack", {"stage": "modal_wait", "success": False, "failure_reason": wait_message, "modal_state": "not_ready"})
|
| 1199 |
+
return wait_message, gr.update(), None, workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1200 |
+
try:
|
| 1201 |
+
workshop_state = ensure_user_workshop_run(workshop_state or {}, user)
|
| 1202 |
+
if len(workshop_state.get("main_candidates") or []) < 4:
|
| 1203 |
+
return "请先生成 4 张主视觉候选,再继续生成 8 表情和背景。", gr.update(), None, workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1204 |
+
workshop_state = generate_expression_pack(workshop_state)
|
| 1205 |
+
workshop_state = generate_background(workshop_state)
|
| 1206 |
+
expression_paths = [workshop_state["expression_assets"][slot] for slot in ("idle", "listening", "thinking", "worried", "smile", "happy", "talk", "focus")]
|
| 1207 |
+
record_workshop_event(
|
| 1208 |
+
user,
|
| 1209 |
+
"generate_expression_pack",
|
| 1210 |
+
{
|
| 1211 |
+
"stage": "assets_ready",
|
| 1212 |
+
"character_id": workshop_state.get("character_id"),
|
| 1213 |
+
"duration_seconds": round(time.perf_counter() - started, 3),
|
| 1214 |
+
"success": True,
|
| 1215 |
+
"image_count": len(expression_paths) + (1 if workshop_state.get("background_asset") else 0),
|
| 1216 |
+
},
|
| 1217 |
+
)
|
| 1218 |
+
return "8 表情和背景已生成,可以开始去背景并打包。", expression_paths, workshop_state.get("background_asset"), workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1219 |
+
except Exception as exc:
|
| 1220 |
+
record_workshop_event(user, "generate_expression_pack", {"stage": "assets_ready", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)})
|
| 1221 |
+
return f"表情或背景生成失败:{exc}", gr.update(), None, workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1222 |
+
|
| 1223 |
+
|
| 1224 |
+
def workshop_package_assets(workshop_state: dict | None, profile: gr.OAuthProfile | None = None):
|
| 1225 |
+
started = time.perf_counter()
|
| 1226 |
+
user = get_current_user(profile)
|
| 1227 |
+
try:
|
| 1228 |
+
user = require_login_for_generation(profile)
|
| 1229 |
+
except ValueError as exc:
|
| 1230 |
+
record_workshop_event(user, "package_assets", {"stage": "auth_required", "success": False, "failure_reason": str(exc)})
|
| 1231 |
+
return str(exc), None, "", workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1232 |
+
try:
|
| 1233 |
+
workshop_state = ensure_user_workshop_run(workshop_state or {}, user)
|
| 1234 |
+
workshop_state = matte_and_package_assets(workshop_state)
|
| 1235 |
+
package_dir = Path(workshop_state["package_dir"])
|
| 1236 |
+
grid_path = package_dir / "generated" / "asset_grid.png"
|
| 1237 |
+
preview_html = render_packaged_stage_preview(workshop_state)
|
| 1238 |
+
record_workshop_event(user, "package_assets", {"stage": "packaged", "character_id": workshop_state.get("character_id"), "duration_seconds": round(time.perf_counter() - started, 3), "success": True})
|
| 1239 |
+
return "角色资产已打包,可预览后安装。", str(grid_path), preview_html, workshop_state, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1240 |
+
except Exception as exc:
|
| 1241 |
+
record_workshop_event(user, "package_assets", {"stage": "packaged", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)})
|
| 1242 |
+
return f"打包失败:{exc}", None, "", workshop_state or {}, _workshop_run_dropdown_update(profile, workshop_state), summarize_workshop_stats()
|
| 1243 |
+
|
| 1244 |
+
|
| 1245 |
+
def workshop_install_character(workshop_state: dict | None, profile: gr.OAuthProfile | None = None):
|
| 1246 |
+
started = time.perf_counter()
|
| 1247 |
+
user = get_current_user(profile)
|
| 1248 |
+
try:
|
| 1249 |
+
user = require_login_for_generation(profile)
|
| 1250 |
+
except ValueError as exc:
|
| 1251 |
+
record_workshop_event(user, "install_character", {"stage": "auth_required", "success": False, "failure_reason": str(exc)})
|
| 1252 |
+
return (
|
| 1253 |
+
str(exc),
|
| 1254 |
+
gr.update(choices=_character_choices()),
|
| 1255 |
+
gr.update(),
|
| 1256 |
+
gr.update(),
|
| 1257 |
+
gr.update(),
|
| 1258 |
+
gr.update(),
|
| 1259 |
+
gr.update(),
|
| 1260 |
+
gr.update(),
|
| 1261 |
+
gr.update(),
|
| 1262 |
+
gr.update(),
|
| 1263 |
+
gr.update(),
|
| 1264 |
+
gr.update(),
|
| 1265 |
+
gr.update(),
|
| 1266 |
+
gr.update(),
|
| 1267 |
+
gr.update(),
|
| 1268 |
+
workshop_state or {},
|
| 1269 |
+
_workshop_run_dropdown_update(profile, workshop_state),
|
| 1270 |
+
summarize_workshop_stats(),
|
| 1271 |
+
)
|
| 1272 |
+
try:
|
| 1273 |
+
workshop_state = ensure_user_workshop_run(workshop_state or {}, user)
|
| 1274 |
+
workshop_state = install_character_package(workshop_state)
|
| 1275 |
+
character_id = workshop_state["installed_character_id"]
|
| 1276 |
+
switch_values = switch_character(character_id)
|
| 1277 |
+
record_workshop_event(user, "install_character", {"stage": "installed", "character_id": character_id, "duration_seconds": round(time.perf_counter() - started, 3), "success": True})
|
| 1278 |
+
return (
|
| 1279 |
+
f"角色已安装:{character_id}",
|
| 1280 |
+
gr.update(choices=_character_choices(), value=character_id),
|
| 1281 |
+
*switch_values,
|
| 1282 |
+
workshop_state,
|
| 1283 |
+
_workshop_run_dropdown_update(profile, workshop_state),
|
| 1284 |
+
summarize_workshop_stats(),
|
| 1285 |
+
)
|
| 1286 |
+
except Exception as exc:
|
| 1287 |
+
record_workshop_event(user, "install_character", {"stage": "installed", "success": False, "failure_reason": str(exc), "duration_seconds": round(time.perf_counter() - started, 3)})
|
| 1288 |
+
return (
|
| 1289 |
+
f"安装失败:{exc}",
|
| 1290 |
+
gr.update(choices=_character_choices()),
|
| 1291 |
+
gr.update(),
|
| 1292 |
+
gr.update(),
|
| 1293 |
+
gr.update(),
|
| 1294 |
+
gr.update(),
|
| 1295 |
+
gr.update(),
|
| 1296 |
+
gr.update(),
|
| 1297 |
+
gr.update(),
|
| 1298 |
+
gr.update(),
|
| 1299 |
+
gr.update(),
|
| 1300 |
+
gr.update(),
|
| 1301 |
+
gr.update(),
|
| 1302 |
+
gr.update(),
|
| 1303 |
+
gr.update(),
|
| 1304 |
+
workshop_state or {},
|
| 1305 |
+
_workshop_run_dropdown_update(profile, workshop_state),
|
| 1306 |
+
summarize_workshop_stats(),
|
| 1307 |
+
)
|
| 1308 |
+
|
| 1309 |
+
|
| 1310 |
+
def _parse_message(message: Any) -> tuple[str, dict[str, list[dict[str, Any]]], str]:
|
| 1311 |
+
if message is None:
|
| 1312 |
+
return "", {"images": []}, ""
|
| 1313 |
+
if isinstance(message, str):
|
| 1314 |
+
text = message.strip()
|
| 1315 |
+
return text, {"images": []}, text
|
| 1316 |
+
|
| 1317 |
+
text = str(message.get("text") or "").strip() if isinstance(message, dict) else ""
|
| 1318 |
+
files = message.get("files") if isinstance(message, dict) else []
|
| 1319 |
+
media_inputs: dict[str, list[dict[str, Any]]] = {"images": []}
|
| 1320 |
+
|
| 1321 |
+
for item in files or []:
|
| 1322 |
+
parsed = _parse_file_item(item)
|
| 1323 |
+
if not parsed:
|
| 1324 |
+
continue
|
| 1325 |
+
if parsed["kind"] == "image":
|
| 1326 |
+
media_inputs["images"].append(parsed)
|
| 1327 |
+
|
| 1328 |
+
if not text:
|
| 1329 |
+
if media_inputs["images"]:
|
| 1330 |
+
text = "请看这张图片,并用你的角色视角回应。"
|
| 1331 |
+
|
| 1332 |
+
attachment_labels = []
|
| 1333 |
+
if media_inputs["images"]:
|
| 1334 |
+
attachment_labels.append(f"{len(media_inputs['images'])} 张图片")
|
| 1335 |
+
display_text = text
|
| 1336 |
+
if attachment_labels:
|
| 1337 |
+
display_text = f"{text}\n\n(已附加:{','.join(attachment_labels)})"
|
| 1338 |
+
return text, media_inputs, display_text
|
| 1339 |
+
|
| 1340 |
+
|
| 1341 |
+
def _parse_file_item(item: Any) -> dict[str, Any] | None:
|
| 1342 |
+
if isinstance(item, str):
|
| 1343 |
+
path = item
|
| 1344 |
+
mime_type = mimetypes.guess_type(path)[0] or ""
|
| 1345 |
+
name = Path(path).name
|
| 1346 |
+
elif isinstance(item, dict):
|
| 1347 |
+
path = item.get("path") or item.get("name") or item.get("orig_name")
|
| 1348 |
+
mime_type = item.get("mime_type") or mimetypes.guess_type(str(path or ""))[0] or ""
|
| 1349 |
+
name = item.get("orig_name") or item.get("name") or Path(str(path or "")).name
|
| 1350 |
+
else:
|
| 1351 |
+
path = getattr(item, "path", None) or getattr(item, "name", None)
|
| 1352 |
+
mime_type = getattr(item, "mime_type", None) or mimetypes.guess_type(str(path or ""))[0] or ""
|
| 1353 |
+
name = getattr(item, "orig_name", None) or Path(str(path or "")).name
|
| 1354 |
+
|
| 1355 |
+
if not path:
|
| 1356 |
+
return None
|
| 1357 |
+
suffix = Path(str(path)).suffix.lower()
|
| 1358 |
+
if mime_type.startswith("image/") or suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif"}:
|
| 1359 |
+
kind = "image"
|
| 1360 |
+
else:
|
| 1361 |
+
return None
|
| 1362 |
+
return {"kind": kind, "path": str(path), "mime_type": mime_type, "name": str(name)}
|
| 1363 |
+
|
| 1364 |
+
|
| 1365 |
+
def chat(
|
| 1366 |
+
message: Any,
|
| 1367 |
+
history: list[dict] | None,
|
| 1368 |
+
state: dict | None,
|
| 1369 |
+
voice_id: str,
|
| 1370 |
+
voice_style: str,
|
| 1371 |
+
voice_speed: float,
|
| 1372 |
+
voice_energy: float,
|
| 1373 |
+
voice_enabled: bool,
|
| 1374 |
+
):
|
| 1375 |
+
if not state:
|
| 1376 |
+
state = _initial_state("star_knight")
|
| 1377 |
+
|
| 1378 |
+
character = state.get("character") or get_character(state.get("character_id", "star_knight"))
|
| 1379 |
+
user_text, media_inputs, display_text = _parse_message(message)
|
| 1380 |
+
history = list(history or [])
|
| 1381 |
+
if not user_text and not media_inputs["images"]:
|
| 1382 |
+
yield history, render_character_stage(character, state["stage"]), None, "等待输入。", _debug_state(state), state
|
| 1383 |
+
return
|
| 1384 |
+
|
| 1385 |
+
voice_state = {
|
| 1386 |
+
**_default_voice_state(character, enabled=voice_enabled),
|
| 1387 |
+
"voice_id": voice_id,
|
| 1388 |
+
"style": voice_style,
|
| 1389 |
+
"emotion": voice_style,
|
| 1390 |
+
"speed": float(voice_speed or 1.0),
|
| 1391 |
+
"energy": float(voice_energy or 0.5),
|
| 1392 |
+
"enabled": bool(voice_enabled),
|
| 1393 |
+
}
|
| 1394 |
+
state["voice"] = voice_state
|
| 1395 |
+
state["last_vision_note"] = _media_note(media_inputs)
|
| 1396 |
+
|
| 1397 |
+
model_history = _history_for_model(history, character)
|
| 1398 |
+
history = history + [{"role": "user", "content": display_text}, {"role": "assistant", "content": _assistant_wait_message()}]
|
| 1399 |
+
partial = ""
|
| 1400 |
+
audio_value = None
|
| 1401 |
+
audio_status = _initial_audio_status(voice_state)
|
| 1402 |
+
yield (
|
| 1403 |
+
history,
|
| 1404 |
+
render_character_stage(character, state["stage"]),
|
| 1405 |
+
audio_value,
|
| 1406 |
+
audio_status,
|
| 1407 |
+
_debug_state(state),
|
| 1408 |
+
state,
|
| 1409 |
+
)
|
| 1410 |
+
|
| 1411 |
+
for event in stream_reply(
|
| 1412 |
+
user_text=user_text,
|
| 1413 |
+
history=model_history,
|
| 1414 |
+
state=state,
|
| 1415 |
+
media_inputs=media_inputs,
|
| 1416 |
+
voice_state=voice_state,
|
| 1417 |
+
):
|
| 1418 |
+
state.setdefault("events", []).append(event)
|
| 1419 |
+
state["events"] = state["events"][-100:]
|
| 1420 |
+
|
| 1421 |
+
if event["type"] == "stage":
|
| 1422 |
+
state["stage"] = {**state.get("stage", {}), **event}
|
| 1423 |
+
elif event["type"] == "text_delta":
|
| 1424 |
+
partial += event.get("text", "")
|
| 1425 |
+
history[-1]["content"] = partial
|
| 1426 |
+
elif event["type"] == "audio":
|
| 1427 |
+
audio_value = event.get("path")
|
| 1428 |
+
audio_status = "语音回复���生成,可点击播放器收听。"
|
| 1429 |
+
elif event["type"] == "error":
|
| 1430 |
+
audio_status = event.get("message", audio_status)
|
| 1431 |
+
|
| 1432 |
+
yield (
|
| 1433 |
+
history,
|
| 1434 |
+
render_character_stage(character, state["stage"]),
|
| 1435 |
+
audio_value,
|
| 1436 |
+
audio_status,
|
| 1437 |
+
_debug_state(state),
|
| 1438 |
+
state,
|
| 1439 |
+
)
|
| 1440 |
+
|
| 1441 |
+
if not audio_value:
|
| 1442 |
+
audio_status = _final_audio_status(voice_state)
|
| 1443 |
+
yield history, render_character_stage(character, state["stage"]), audio_value, audio_status, _debug_state(state), state
|
| 1444 |
+
|
| 1445 |
+
|
| 1446 |
+
def _media_note(media_inputs: dict[str, list[dict[str, Any]]]) -> str | None:
|
| 1447 |
+
parts = []
|
| 1448 |
+
if media_inputs.get("images"):
|
| 1449 |
+
parts.append(f"用户本轮附加了 {len(media_inputs['images'])} 张图片。")
|
| 1450 |
+
return " ".join(parts) if parts else None
|
| 1451 |
+
|
| 1452 |
+
|
| 1453 |
+
def _initial_audio_status(voice_state: dict[str, Any]) -> str:
|
| 1454 |
+
if not voice_state.get("enabled", True):
|
| 1455 |
+
return "语音生成已关闭。"
|
| 1456 |
+
if not os.environ.get("VC_MODAL_TTS_URL"):
|
| 1457 |
+
if _local_tts_service_exists():
|
| 1458 |
+
return "TTS endpoint 未绑定;modal_apps/modal_tts.py 已存在,部署后设置 VC_MODAL_TTS_URL 即可生成语音。"
|
| 1459 |
+
return "语音模型未配置。"
|
| 1460 |
+
return "正在等待语音回复。"
|
| 1461 |
+
|
| 1462 |
+
|
| 1463 |
+
def _final_audio_status(voice_state: dict[str, Any]) -> str:
|
| 1464 |
+
if not voice_state.get("enabled", True):
|
| 1465 |
+
return "语音生成已关闭。"
|
| 1466 |
+
if not os.environ.get("VC_MODAL_TTS_URL"):
|
| 1467 |
+
if _local_tts_service_exists():
|
| 1468 |
+
return "TTS endpoint 未绑定;modal_apps/modal_tts.py 已存在,部署后设置 VC_MODAL_TTS_URL 即可生成语音。"
|
| 1469 |
+
return "语音模型未配置。"
|
| 1470 |
+
return "本轮没有生成可播放语音。"
|
| 1471 |
+
|
| 1472 |
+
|
| 1473 |
+
def _assistant_wait_message() -> str:
|
| 1474 |
+
if os.environ.get("VC_USE_MOCK") == "1":
|
| 1475 |
+
return "正在生成回复..."
|
| 1476 |
+
return "正在连接主模型。如果服务刚休眠,会先完成冷启动和权重加载。"
|
| 1477 |
+
|
| 1478 |
+
|
| 1479 |
+
def _local_tts_service_exists() -> bool:
|
| 1480 |
+
return (ROOT / "modal_apps" / "modal_tts.py").exists()
|
| 1481 |
+
|
| 1482 |
+
|
| 1483 |
+
def _debug_state(state: dict) -> dict[str, Any]:
|
| 1484 |
+
return {
|
| 1485 |
+
"character_id": state.get("character_id"),
|
| 1486 |
+
"stage": state.get("stage"),
|
| 1487 |
+
"voice": state.get("voice"),
|
| 1488 |
+
"last_vision_note": state.get("last_vision_note"),
|
| 1489 |
+
"events": state.get("events", [])[-25:],
|
| 1490 |
+
}
|
| 1491 |
+
|
| 1492 |
+
|
| 1493 |
+
def build_demo() -> gr.Blocks:
|
| 1494 |
+
default_id = "star_knight"
|
| 1495 |
+
default_state = _initial_state(default_id)
|
| 1496 |
+
default_character = default_state["character"]
|
| 1497 |
+
default_voice = default_state["voice"]
|
| 1498 |
+
|
| 1499 |
+
with gr.Blocks(title="Virtual Characters", elem_id="vc-root") as demo:
|
| 1500 |
+
state = gr.State(default_state)
|
| 1501 |
+
workshop_state = gr.State({})
|
| 1502 |
+
appearance_marker = gr.HTML(value=_appearance_marker("day"), visible=True)
|
| 1503 |
+
|
| 1504 |
+
with gr.Tabs(elem_classes=["vc-tabs"]):
|
| 1505 |
+
with gr.Tab("对话"):
|
| 1506 |
+
with gr.Row(elem_classes=["vc-topbar"]):
|
| 1507 |
+
with gr.Column(scale=1, min_width=320, elem_classes=["vc-status-wrap"]):
|
| 1508 |
+
model_status = gr.HTML(value=statuses_markdown(initial_model_statuses()), elem_classes=["vc-panel"])
|
| 1509 |
+
with gr.Column(scale=0, min_width=240, elem_classes=["vc-status-actions"]):
|
| 1510 |
+
appearance_mode = gr.Radio(
|
| 1511 |
+
choices=APPEARANCE_CHOICES,
|
| 1512 |
+
value="day",
|
| 1513 |
+
label="视觉模式",
|
| 1514 |
+
elem_classes=["vc-appearance-mode"],
|
| 1515 |
+
)
|
| 1516 |
+
start_model = gr.Button("启动主模型", variant="primary")
|
| 1517 |
+
refresh_status = gr.Button("刷新模型状态", variant="secondary")
|
| 1518 |
+
model_action_status = gr.Markdown(value=_initial_model_action_note(), elem_classes=["vc-model-action"])
|
| 1519 |
+
|
| 1520 |
+
with gr.Row(equal_height=True):
|
| 1521 |
+
with gr.Column(scale=1, min_width=300, elem_classes=["vc-panel", "vc-left"]):
|
| 1522 |
+
character_select = gr.Radio(
|
| 1523 |
+
choices=_character_choices(),
|
| 1524 |
+
value=default_id,
|
| 1525 |
+
label="角色",
|
| 1526 |
+
)
|
| 1527 |
+
character_header = gr.HTML(value=_character_header_html(default_character))
|
| 1528 |
+
character_card = gr.Markdown(
|
| 1529 |
+
value=format_character_card_markdown(default_character),
|
| 1530 |
+
elem_classes=["vc-character-card"],
|
| 1531 |
+
)
|
| 1532 |
+
|
| 1533 |
+
with gr.Column(scale=3, min_width=430, elem_classes=["vc-stage-col"]):
|
| 1534 |
+
stage = gr.HTML(
|
| 1535 |
+
value=render_character_stage(default_character, default_state["stage"]),
|
| 1536 |
+
elem_id="character-stage",
|
| 1537 |
+
)
|
| 1538 |
+
with gr.Row(elem_classes=["vc-input-dock"]):
|
| 1539 |
+
message_input = gr.MultimodalTextbox(
|
| 1540 |
+
label="输入",
|
| 1541 |
+
placeholder="输入文字,也可以附加图片...",
|
| 1542 |
+
sources=["upload"],
|
| 1543 |
+
file_types=["image"],
|
| 1544 |
+
file_count="multiple",
|
| 1545 |
+
submit_btn="发送",
|
| 1546 |
+
stop_btn=True,
|
| 1547 |
+
elem_id="vc-message",
|
| 1548 |
+
)
|
| 1549 |
+
|
| 1550 |
+
with gr.Column(scale=1, min_width=340, elem_classes=["vc-panel", "vc-output", "vc-right"]):
|
| 1551 |
+
chatbot = gr.Chatbot(
|
| 1552 |
+
label="输出",
|
| 1553 |
+
value=_opening_history(default_character),
|
| 1554 |
+
height=430,
|
| 1555 |
+
)
|
| 1556 |
+
audio = gr.Audio(label="语音回复", autoplay=False, interactive=False, elem_id="voice-output")
|
| 1557 |
+
audio_status = gr.Markdown(value="等待新的语音回复。", elem_classes=["vc-audio-status"])
|
| 1558 |
+
|
| 1559 |
+
with gr.Accordion("语音控制", open=False):
|
| 1560 |
+
voice_enabled = gr.Checkbox(value=True, label="生成语音回复")
|
| 1561 |
+
voice_id = gr.Dropdown(
|
| 1562 |
+
choices=_voice_choices(default_character),
|
| 1563 |
+
value=default_voice["voice_id"],
|
| 1564 |
+
label="音色",
|
| 1565 |
+
)
|
| 1566 |
+
voice_style = gr.Dropdown(
|
| 1567 |
+
choices=VOICE_STYLE_CHOICES,
|
| 1568 |
+
value=default_voice["style"],
|
| 1569 |
+
label="语气",
|
| 1570 |
+
)
|
| 1571 |
+
voice_speed = gr.Slider(0.75, 1.25, value=default_voice["speed"], step=0.01, label="语速")
|
| 1572 |
+
voice_energy = gr.Slider(0.2, 1.0, value=default_voice["energy"], step=0.05, label="表现力")
|
| 1573 |
+
|
| 1574 |
+
with gr.Accordion("事件与状态调试", open=False):
|
| 1575 |
+
with gr.Row():
|
| 1576 |
+
debug = gr.JSON(value={"events": []}, label="事件流")
|
| 1577 |
+
debug_models = gr.JSON(value=statuses_json(initial_model_statuses()), label="模型状态")
|
| 1578 |
+
debug_workshop_stats = gr.JSON(value=summarize_workshop_stats(), label="角色工坊统计")
|
| 1579 |
+
|
| 1580 |
+
with gr.Tab("角色工坊"):
|
| 1581 |
+
with gr.Column(elem_id="vc-workshop-shell", elem_classes=["vc-workshop-shell"]):
|
| 1582 |
+
with gr.Row(elem_classes=["vc-topbar"]):
|
| 1583 |
+
with gr.Column(scale=1, min_width=320, elem_classes=["vc-status-wrap"]):
|
| 1584 |
+
workshop_model_status = gr.HTML(value=statuses_markdown(initial_model_statuses()), elem_classes=["vc-panel"])
|
| 1585 |
+
with gr.Column(scale=0, min_width=240, elem_classes=["vc-status-actions"]):
|
| 1586 |
+
if _hf_oauth_available():
|
| 1587 |
+
workshop_login = gr.LoginButton("使用 Hugging Face 登录")
|
| 1588 |
+
else:
|
| 1589 |
+
workshop_login = None
|
| 1590 |
+
gr.Markdown(
|
| 1591 |
+
"本地 OAuth 预演需要先运行 `hf auth login` 或设置 `HF_TOKEN`,重启后这里会出现“使用 Hugging Face 登录”按钮;Space 上用户会直接点击按钮登录。",
|
| 1592 |
+
elem_classes=["vc-model-action"],
|
| 1593 |
+
)
|
| 1594 |
+
workshop_start_model = gr.Button("启动主模型", variant="primary")
|
| 1595 |
+
workshop_refresh_status = gr.Button("刷新模型状态", variant="secondary")
|
| 1596 |
+
workshop_model_action_status = gr.Markdown(value=_initial_model_action_note(), elem_classes=["vc-model-action"])
|
| 1597 |
+
with gr.Row(equal_height=True):
|
| 1598 |
+
with gr.Column(scale=1, min_width=360, elem_classes=["vc-panel", "vc-workshop-grid"]):
|
| 1599 |
+
workshop_user_status = gr.Markdown(value=workshop_login_status(), elem_classes=["vc-workshop-status"])
|
| 1600 |
+
with gr.Column(scale=2, min_width=460, elem_classes=["vc-panel", "vc-workshop-grid"]):
|
| 1601 |
+
workshop_run_choice = gr.Dropdown(
|
| 1602 |
+
choices=list_user_workshop_runs(get_current_user(None)),
|
| 1603 |
+
value=None,
|
| 1604 |
+
label="我的生成任务",
|
| 1605 |
+
interactive=True,
|
| 1606 |
+
)
|
| 1607 |
+
with gr.Row():
|
| 1608 |
+
refresh_workshop_runs = gr.Button("刷新任务列表", variant="secondary")
|
| 1609 |
+
load_workshop_run_button = gr.Button("加载任务继续", variant="secondary")
|
| 1610 |
+
load_recent_workshop_run = gr.Button("加载最近任务", variant="secondary")
|
| 1611 |
+
|
| 1612 |
+
with gr.Row(equal_height=True):
|
| 1613 |
+
with gr.Column(scale=1, min_width=360, elem_classes=["vc-panel", "vc-workshop-grid"]):
|
| 1614 |
+
workshop_status = gr.Markdown(value="先导入 Tavern JSON,或手填角色设定创建草案。", elem_classes=["vc-workshop-status"])
|
| 1615 |
+
tavern_file = gr.File(label="Tavern JSON 角色卡", file_types=[".json"])
|
| 1616 |
+
import_tavern = gr.Button("导入 Tavern JSON", variant="secondary")
|
| 1617 |
+
gr.Markdown("### 手填角色设定")
|
| 1618 |
+
workshop_name = gr.Textbox(label="角色名", value="星核")
|
| 1619 |
+
workshop_description = gr.Textbox(label="描述", lines=4, value="一名银白短发、青绿色眼睛的原创科幻通讯员。")
|
| 1620 |
+
workshop_personality = gr.Textbox(label="性格", lines=3, value="冷静、温柔、边界清晰")
|
| 1621 |
+
workshop_scenario = gr.Textbox(label="场景", lines=3, value="用户正在通过虚拟通讯端与角色对话。")
|
| 1622 |
+
workshop_first_mes = gr.Textbox(label="开场白", lines=2, value="我在。现在频道很稳定。")
|
| 1623 |
+
workshop_tags = gr.Textbox(label="标签", value="原创, 科幻, 通讯端")
|
| 1624 |
+
create_draft = gr.Button("创建草案", variant="primary")
|
| 1625 |
+
workshop_draft_card = gr.Markdown(value="", elem_classes=["vc-character-card"])
|
| 1626 |
+
|
| 1627 |
+
with gr.Column(scale=2, min_width=460, elem_classes=["vc-panel", "vc-workshop-grid"]):
|
| 1628 |
+
with gr.Row():
|
| 1629 |
+
generate_candidates = gr.Button("生成 4 张主视觉候选", variant="primary")
|
| 1630 |
+
generate_assets = gr.Button("生成 8 表情和背景", variant="secondary")
|
| 1631 |
+
selected_candidate_index = gr.Number(value=0, visible=False)
|
| 1632 |
+
selected_candidate_label = gr.Markdown(value="当前选择:无。", elem_classes=["vc-workshop-status"])
|
| 1633 |
+
main_gallery = gr.Gallery(label="主视觉候选", columns=4, height=260, object_fit="contain")
|
| 1634 |
+
expression_gallery = gr.Gallery(label="8 表情/动作独立图片", columns=4, height=360, object_fit="contain")
|
| 1635 |
+
background_preview = gr.Image(label="背景图", type="filepath", height=180)
|
| 1636 |
+
|
| 1637 |
+
with gr.Column(scale=1, min_width=360, elem_classes=["vc-panel", "vc-workshop-grid"]):
|
| 1638 |
+
package_assets = gr.Button("去背景并打包预览", variant="secondary")
|
| 1639 |
+
package_grid = gr.Image(label="资产包网格", type="filepath", height=300)
|
| 1640 |
+
package_stage_preview = gr.HTML(value="")
|
| 1641 |
+
install_character = gr.Button("安装并切换到新角色", variant="primary")
|
| 1642 |
+
|
| 1643 |
+
refresh_status.click(
|
| 1644 |
+
refresh_model_status_both,
|
| 1645 |
+
outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status],
|
| 1646 |
+
show_progress="hidden",
|
| 1647 |
+
)
|
| 1648 |
+
start_model.click(
|
| 1649 |
+
start_main_model,
|
| 1650 |
+
outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status],
|
| 1651 |
+
show_progress="minimal",
|
| 1652 |
+
)
|
| 1653 |
+
workshop_refresh_status.click(
|
| 1654 |
+
refresh_model_status_both,
|
| 1655 |
+
outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status],
|
| 1656 |
+
show_progress="hidden",
|
| 1657 |
+
)
|
| 1658 |
+
workshop_start_model.click(
|
| 1659 |
+
start_main_model,
|
| 1660 |
+
outputs=[model_status, workshop_model_status, debug_models, model_action_status, workshop_model_action_status],
|
| 1661 |
+
show_progress="minimal",
|
| 1662 |
+
)
|
| 1663 |
+
if workshop_login is not None:
|
| 1664 |
+
workshop_login.click(
|
| 1665 |
+
workshop_refresh_runs,
|
| 1666 |
+
outputs=[workshop_user_status, workshop_run_choice, debug_workshop_stats],
|
| 1667 |
+
show_progress="hidden",
|
| 1668 |
+
)
|
| 1669 |
+
refresh_workshop_runs.click(
|
| 1670 |
+
workshop_refresh_runs,
|
| 1671 |
+
outputs=[workshop_user_status, workshop_run_choice, debug_workshop_stats],
|
| 1672 |
+
show_progress="hidden",
|
| 1673 |
+
)
|
| 1674 |
+
load_workshop_run_button.click(
|
| 1675 |
+
workshop_load_selected_run,
|
| 1676 |
+
inputs=[workshop_run_choice],
|
| 1677 |
+
outputs=[
|
| 1678 |
+
workshop_status,
|
| 1679 |
+
workshop_draft_card,
|
| 1680 |
+
workshop_state,
|
| 1681 |
+
main_gallery,
|
| 1682 |
+
selected_candidate_index,
|
| 1683 |
+
selected_candidate_label,
|
| 1684 |
+
expression_gallery,
|
| 1685 |
+
background_preview,
|
| 1686 |
+
package_grid,
|
| 1687 |
+
package_stage_preview,
|
| 1688 |
+
workshop_run_choice,
|
| 1689 |
+
debug_workshop_stats,
|
| 1690 |
+
],
|
| 1691 |
+
show_progress="minimal",
|
| 1692 |
+
)
|
| 1693 |
+
load_recent_workshop_run.click(
|
| 1694 |
+
workshop_load_recent_run,
|
| 1695 |
+
outputs=[
|
| 1696 |
+
workshop_status,
|
| 1697 |
+
workshop_draft_card,
|
| 1698 |
+
workshop_state,
|
| 1699 |
+
main_gallery,
|
| 1700 |
+
selected_candidate_index,
|
| 1701 |
+
selected_candidate_label,
|
| 1702 |
+
expression_gallery,
|
| 1703 |
+
background_preview,
|
| 1704 |
+
package_grid,
|
| 1705 |
+
package_stage_preview,
|
| 1706 |
+
workshop_run_choice,
|
| 1707 |
+
debug_workshop_stats,
|
| 1708 |
+
],
|
| 1709 |
+
show_progress="minimal",
|
| 1710 |
+
)
|
| 1711 |
+
appearance_mode.change(
|
| 1712 |
+
_appearance_marker,
|
| 1713 |
+
inputs=[appearance_mode],
|
| 1714 |
+
outputs=[appearance_marker],
|
| 1715 |
+
show_progress="hidden",
|
| 1716 |
+
)
|
| 1717 |
+
character_select.change(
|
| 1718 |
+
switch_character,
|
| 1719 |
+
inputs=[character_select],
|
| 1720 |
+
outputs=[
|
| 1721 |
+
character_header,
|
| 1722 |
+
character_card,
|
| 1723 |
+
stage,
|
| 1724 |
+
state,
|
| 1725 |
+
chatbot,
|
| 1726 |
+
debug,
|
| 1727 |
+
voice_id,
|
| 1728 |
+
voice_style,
|
| 1729 |
+
voice_speed,
|
| 1730 |
+
voice_energy,
|
| 1731 |
+
voice_enabled,
|
| 1732 |
+
audio_status,
|
| 1733 |
+
audio,
|
| 1734 |
+
],
|
| 1735 |
+
show_progress="hidden",
|
| 1736 |
+
)
|
| 1737 |
+
message_input.submit(
|
| 1738 |
+
chat,
|
| 1739 |
+
inputs=[
|
| 1740 |
+
message_input,
|
| 1741 |
+
chatbot,
|
| 1742 |
+
state,
|
| 1743 |
+
voice_id,
|
| 1744 |
+
voice_style,
|
| 1745 |
+
voice_speed,
|
| 1746 |
+
voice_energy,
|
| 1747 |
+
voice_enabled,
|
| 1748 |
+
],
|
| 1749 |
+
outputs=[chatbot, stage, audio, audio_status, debug, state],
|
| 1750 |
+
show_progress="hidden",
|
| 1751 |
+
).then(lambda: None, outputs=[message_input], show_progress="hidden")
|
| 1752 |
+
create_draft.click(
|
| 1753 |
+
workshop_create_from_form,
|
| 1754 |
+
inputs=[
|
| 1755 |
+
workshop_name,
|
| 1756 |
+
workshop_description,
|
| 1757 |
+
workshop_personality,
|
| 1758 |
+
workshop_scenario,
|
| 1759 |
+
workshop_first_mes,
|
| 1760 |
+
workshop_tags,
|
| 1761 |
+
],
|
| 1762 |
+
outputs=[
|
| 1763 |
+
workshop_status,
|
| 1764 |
+
workshop_draft_card,
|
| 1765 |
+
workshop_state,
|
| 1766 |
+
main_gallery,
|
| 1767 |
+
selected_candidate_index,
|
| 1768 |
+
selected_candidate_label,
|
| 1769 |
+
expression_gallery,
|
| 1770 |
+
background_preview,
|
| 1771 |
+
package_grid,
|
| 1772 |
+
package_stage_preview,
|
| 1773 |
+
],
|
| 1774 |
+
show_progress="hidden",
|
| 1775 |
+
)
|
| 1776 |
+
import_tavern.click(
|
| 1777 |
+
workshop_import_tavern,
|
| 1778 |
+
inputs=[tavern_file],
|
| 1779 |
+
outputs=[
|
| 1780 |
+
workshop_status,
|
| 1781 |
+
workshop_draft_card,
|
| 1782 |
+
workshop_state,
|
| 1783 |
+
main_gallery,
|
| 1784 |
+
selected_candidate_index,
|
| 1785 |
+
selected_candidate_label,
|
| 1786 |
+
expression_gallery,
|
| 1787 |
+
background_preview,
|
| 1788 |
+
package_grid,
|
| 1789 |
+
package_stage_preview,
|
| 1790 |
+
],
|
| 1791 |
+
show_progress="hidden",
|
| 1792 |
+
)
|
| 1793 |
+
generate_candidates.click(
|
| 1794 |
+
workshop_generate_main_candidates,
|
| 1795 |
+
inputs=[workshop_state],
|
| 1796 |
+
outputs=[workshop_status, main_gallery, workshop_state, selected_candidate_label, workshop_run_choice, debug_workshop_stats],
|
| 1797 |
+
show_progress="full",
|
| 1798 |
+
show_progress_on=main_gallery,
|
| 1799 |
+
concurrency_limit=1,
|
| 1800 |
+
concurrency_id="character_workshop_generation",
|
| 1801 |
+
)
|
| 1802 |
+
main_gallery.select(
|
| 1803 |
+
workshop_select_candidate,
|
| 1804 |
+
inputs=[workshop_state],
|
| 1805 |
+
outputs=[workshop_state, selected_candidate_label],
|
| 1806 |
+
show_progress="hidden",
|
| 1807 |
+
)
|
| 1808 |
+
generate_assets.click(
|
| 1809 |
+
workshop_generate_assets,
|
| 1810 |
+
inputs=[workshop_state],
|
| 1811 |
+
outputs=[workshop_status, expression_gallery, background_preview, workshop_state, workshop_run_choice, debug_workshop_stats],
|
| 1812 |
+
show_progress="full",
|
| 1813 |
+
show_progress_on=[expression_gallery, background_preview],
|
| 1814 |
+
concurrency_limit=1,
|
| 1815 |
+
concurrency_id="character_workshop_generation",
|
| 1816 |
+
)
|
| 1817 |
+
package_assets.click(
|
| 1818 |
+
workshop_package_assets,
|
| 1819 |
+
inputs=[workshop_state],
|
| 1820 |
+
outputs=[workshop_status, package_grid, package_stage_preview, workshop_state, workshop_run_choice, debug_workshop_stats],
|
| 1821 |
+
show_progress="full",
|
| 1822 |
+
show_progress_on=[package_grid, package_stage_preview],
|
| 1823 |
+
concurrency_limit=1,
|
| 1824 |
+
concurrency_id="character_workshop_generation",
|
| 1825 |
+
)
|
| 1826 |
+
install_character.click(
|
| 1827 |
+
workshop_install_character,
|
| 1828 |
+
inputs=[workshop_state],
|
| 1829 |
+
outputs=[
|
| 1830 |
+
workshop_status,
|
| 1831 |
+
character_select,
|
| 1832 |
+
character_header,
|
| 1833 |
+
character_card,
|
| 1834 |
+
stage,
|
| 1835 |
+
state,
|
| 1836 |
+
chatbot,
|
| 1837 |
+
debug,
|
| 1838 |
+
voice_id,
|
| 1839 |
+
voice_style,
|
| 1840 |
+
voice_speed,
|
| 1841 |
+
voice_energy,
|
| 1842 |
+
voice_enabled,
|
| 1843 |
+
audio_status,
|
| 1844 |
+
audio,
|
| 1845 |
+
workshop_state,
|
| 1846 |
+
workshop_run_choice,
|
| 1847 |
+
debug_workshop_stats,
|
| 1848 |
+
],
|
| 1849 |
+
show_progress="minimal",
|
| 1850 |
+
concurrency_limit=1,
|
| 1851 |
+
concurrency_id="character_workshop_generation",
|
| 1852 |
+
)
|
| 1853 |
+
return demo
|
| 1854 |
+
|
| 1855 |
+
|
| 1856 |
+
def launch_app(*, prevent_thread_lock: bool = False):
|
| 1857 |
+
demo = build_demo().queue()
|
| 1858 |
+
demo.launch(
|
| 1859 |
+
theme=_theme(),
|
| 1860 |
+
css=APP_CSS,
|
| 1861 |
+
server_name=os.environ.get("VC_GRADIO_SERVER_NAME", "127.0.0.1"),
|
| 1862 |
+
server_port=int(os.environ.get("VC_GRADIO_PORT", "7861")),
|
| 1863 |
+
prevent_thread_lock=prevent_thread_lock,
|
| 1864 |
+
)
|
| 1865 |
+
return demo
|
| 1866 |
+
|
| 1867 |
+
|
| 1868 |
+
def _keep_alive_until_interrupt(demo) -> None:
|
| 1869 |
+
try:
|
| 1870 |
+
while True:
|
| 1871 |
+
time.sleep(3600)
|
| 1872 |
+
except KeyboardInterrupt:
|
| 1873 |
+
demo.close()
|
| 1874 |
+
|
| 1875 |
+
|
| 1876 |
+
if __name__ == "__main__":
|
| 1877 |
+
_keep_alive_until_interrupt(launch_app(prevent_thread_lock=True))
|
assets/characters/star/focus.png
ADDED
|
Git LFS Details
|
assets/characters/star/focus_preview_check.png
ADDED
|
Git LFS Details
|
assets/characters/star/focus_preview_check2.png
ADDED
|
Git LFS Details
|
assets/characters/star/happy.png
ADDED
|
Git LFS Details
|
assets/characters/star/idle.png
ADDED
|
Git LFS Details
|
assets/characters/star/listening.png
ADDED
|
Git LFS Details
|
assets/characters/star/smile.png
ADDED
|
Git LFS Details
|
assets/characters/star/talk.png
ADDED
|
Git LFS Details
|
assets/characters/star/thinking.png
ADDED
|
Git LFS Details
|
assets/characters/star/worried.png
ADDED
|
Git LFS Details
|
demo_modal_stack.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import tempfile
|
| 3 |
+
import time
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import gradio as gr
|
| 7 |
+
|
| 8 |
+
from modal_apps.modal_llm import PersonaLLM
|
| 9 |
+
from modal_apps.modal_tts import CharacterTTS
|
| 10 |
+
from src.character_registry import CHARACTER_PACKAGES, get_character
|
| 11 |
+
from src.stage_driver import render_character_stage
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
APP_CSS = """
|
| 15 |
+
#modal-demo-stage iframe, #modal-demo-stage { min-height: 460px; }
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _character_choices() -> list[tuple[str, str]]:
|
| 20 |
+
return [(character["display_name"], character_id) for character_id, character in CHARACTER_PACKAGES.items()]
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _split_sentences(text: str) -> list[str]:
|
| 24 |
+
return [part.strip() for part in re.split(r"(?<=[。!?!?;;])\\s*", text) if part.strip()] or [text.strip()]
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _write_wav(audio: bytes, prefix: str = "vc_tts_") -> str:
|
| 28 |
+
handle = tempfile.NamedTemporaryFile(prefix=prefix, suffix=".wav", delete=False)
|
| 29 |
+
handle.write(audio)
|
| 30 |
+
handle.close()
|
| 31 |
+
return handle.name
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def chat_once(message: str, history: list[dict], character_id: str, tts_enabled: bool):
|
| 35 |
+
if not message.strip():
|
| 36 |
+
yield history, None, {"status": "empty"}
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
character = get_character(character_id)
|
| 40 |
+
history = history + [{"role": "user", "content": message}, {"role": "assistant", "content": "Modal LLM 正在生成..."}]
|
| 41 |
+
yield history, None, {"status": "llm_generating"}
|
| 42 |
+
|
| 43 |
+
started = time.perf_counter()
|
| 44 |
+
llm_result = PersonaLLM().generate_text.remote(
|
| 45 |
+
user_text=message,
|
| 46 |
+
character=character,
|
| 47 |
+
max_new_tokens=120,
|
| 48 |
+
)
|
| 49 |
+
reply = llm_result["text"]
|
| 50 |
+
history[-1]["content"] = reply
|
| 51 |
+
debug = {
|
| 52 |
+
"status": "llm_done",
|
| 53 |
+
"llm_remote_s": llm_result.get("remote_s"),
|
| 54 |
+
"llm_output_tokens": llm_result.get("output_tokens"),
|
| 55 |
+
"client_elapsed_s": round(time.perf_counter() - started, 3),
|
| 56 |
+
}
|
| 57 |
+
yield history, None, debug
|
| 58 |
+
|
| 59 |
+
if not tts_enabled:
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
for index, sentence in enumerate(_split_sentences(reply), start=1):
|
| 63 |
+
if not sentence:
|
| 64 |
+
continue
|
| 65 |
+
debug = {**debug, "status": "tts_generating", "tts_sentence_index": index, "tts_sentence": sentence}
|
| 66 |
+
yield history, None, debug
|
| 67 |
+
tts_started = time.perf_counter()
|
| 68 |
+
audio = CharacterTTS().synthesize.remote(text=sentence, emotion="neutral")
|
| 69 |
+
audio_path = _write_wav(audio)
|
| 70 |
+
debug = {
|
| 71 |
+
**debug,
|
| 72 |
+
"status": "tts_chunk_done",
|
| 73 |
+
"tts_sentence_index": index,
|
| 74 |
+
"tts_remote_client_s": round(time.perf_counter() - tts_started, 3),
|
| 75 |
+
"audio_path": audio_path,
|
| 76 |
+
}
|
| 77 |
+
yield history, audio_path, debug
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def switch_character(character_id: str):
|
| 81 |
+
character = get_character(character_id)
|
| 82 |
+
stage = {"expression": "idle", "motion": "breathe", "intensity": 0.35}
|
| 83 |
+
return character["summary"], render_character_stage(character, stage)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def build_demo() -> gr.Blocks:
|
| 87 |
+
default_id = "memory_girl"
|
| 88 |
+
default_character = get_character(default_id)
|
| 89 |
+
default_stage = {"expression": "idle", "motion": "breathe", "intensity": 0.35}
|
| 90 |
+
|
| 91 |
+
with gr.Blocks(title="Modal Virtual Character Smoke Demo") as demo:
|
| 92 |
+
with gr.Row():
|
| 93 |
+
with gr.Column(scale=1, min_width=260):
|
| 94 |
+
character_select = gr.Radio(_character_choices(), value=default_id, label="角色")
|
| 95 |
+
character_summary = gr.Markdown(default_character["summary"])
|
| 96 |
+
tts_enabled = gr.Checkbox(value=True, label="启用 Chatterbox TTS")
|
| 97 |
+
with gr.Column(scale=2, min_width=360):
|
| 98 |
+
stage = gr.HTML(
|
| 99 |
+
render_character_stage(default_character, default_stage),
|
| 100 |
+
elem_id="modal-demo-stage",
|
| 101 |
+
min_height=460,
|
| 102 |
+
)
|
| 103 |
+
with gr.Column(scale=2, min_width=360):
|
| 104 |
+
chatbot = gr.Chatbot(label="Modal 对话", height=380)
|
| 105 |
+
message = gr.Textbox(label="输入", lines=2, submit_btn=True)
|
| 106 |
+
audio = gr.Audio(label="分句语音", autoplay=True)
|
| 107 |
+
|
| 108 |
+
debug = gr.JSON(label="调试")
|
| 109 |
+
|
| 110 |
+
character_select.change(switch_character, inputs=[character_select], outputs=[character_summary, stage])
|
| 111 |
+
message.submit(
|
| 112 |
+
chat_once,
|
| 113 |
+
inputs=[message, chatbot, character_select, tts_enabled],
|
| 114 |
+
outputs=[chatbot, audio, debug],
|
| 115 |
+
).then(lambda: "", outputs=[message])
|
| 116 |
+
|
| 117 |
+
return demo
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
if __name__ == "__main__":
|
| 121 |
+
build_demo().queue().launch(css=APP_CSS, server_name="127.0.0.1", server_port=7862)
|
modal_apps/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Modal apps
|
| 2 |
+
|
| 3 |
+
These files define separate Modal deployments for the virtual character project.
|
| 4 |
+
|
| 5 |
+
## Setup
|
| 6 |
+
|
| 7 |
+
```powershell
|
| 8 |
+
python -m pip install -r requirements.txt
|
| 9 |
+
modal setup
|
| 10 |
+
modal secret create hf-token HF_TOKEN=hf_xxx
|
| 11 |
+
```
|
| 12 |
+
|
| 13 |
+
You must also accept gated model licenses on Hugging Face before Modal can download those weights.
|
| 14 |
+
|
| 15 |
+
If your Modal Secret uses a different name, set it before running checks or deploys:
|
| 16 |
+
|
| 17 |
+
```powershell
|
| 18 |
+
$env:VC_HF_SECRET_NAME="your-secret-name"
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
## First checks
|
| 22 |
+
|
| 23 |
+
Run remote method health checks:
|
| 24 |
+
|
| 25 |
+
```powershell
|
| 26 |
+
modal run modal_apps/modal_ping.py
|
| 27 |
+
modal run modal_apps/modal_hf_check.py
|
| 28 |
+
python scripts/check_modal_connectivity.py --mode remote-methods
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
`modal_ping.py` is CPU-only and only checks login/connectivity. `modal_hf_check.py` is also CPU-only and checks whether Modal can read `hf-token` and query the selected Hugging Face model metadata without downloading weights. The health checks in `check_modal_connectivity.py` start the service containers but do not load model weights. Actual generation tests will load models and consume GPU credits.
|
| 32 |
+
|
| 33 |
+
Benchmark Gemma on Modal:
|
| 34 |
+
|
| 35 |
+
```powershell
|
| 36 |
+
$env:VC_BENCH_MODEL="google/gemma-4-12B-it"
|
| 37 |
+
$env:VC_BENCH_GPU="L40S"
|
| 38 |
+
modal run modal_apps/modal_gemma_benchmark.py --max-new-tokens 64
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
Benchmark Gemma through vLLM:
|
| 42 |
+
|
| 43 |
+
```powershell
|
| 44 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 45 |
+
$env:VC_VLLM_VERSION="0.22.1"
|
| 46 |
+
$env:VC_VLLM_GPU="L40S"
|
| 47 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 48 |
+
modal run modal_apps/modal_vllm_gemma.py --max-tokens 128
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
Note: `vllm==0.22.1` is the latest PyPI stable release checked on 2026-06-12, but it still does not run `google/gemma-4-12B-it` correctly in our Modal test. For Gemma 4 12B, use this only as a regression check.
|
| 52 |
+
|
| 53 |
+
Benchmark Gemma through vLLM nightly:
|
| 54 |
+
|
| 55 |
+
```powershell
|
| 56 |
+
$env:PYTHONIOENCODING="utf-8"
|
| 57 |
+
$env:PYTHONUTF8="1"
|
| 58 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 59 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 60 |
+
$env:VC_VLLM_PACKAGE="vllm==0.22.1rc1.dev468+gfbc3a1907.cu129"
|
| 61 |
+
$env:VC_VLLM_EXTRA_INDEX_URL="https://wheels.vllm.ai/nightly/cu129"
|
| 62 |
+
$env:VC_VLLM_UV_EXTRA_OPTIONS="--index-strategy unsafe-best-match"
|
| 63 |
+
$env:VC_VLLM_PRE="1"
|
| 64 |
+
$env:VC_VLLM_GPU="L40S"
|
| 65 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 66 |
+
modal run modal_apps/modal_vllm_gemma.py --max-tokens 128
|
| 67 |
+
```
|
| 68 |
+
|
| 69 |
+
Deploy the working vLLM nightly endpoint:
|
| 70 |
+
|
| 71 |
+
```powershell
|
| 72 |
+
$env:PYTHONIOENCODING="utf-8"
|
| 73 |
+
$env:PYTHONUTF8="1"
|
| 74 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 75 |
+
$env:VC_VLLM_MODEL="google/gemma-4-12B-it"
|
| 76 |
+
$env:VC_VLLM_PACKAGE="vllm==0.22.1rc1.dev468+gfbc3a1907.cu129"
|
| 77 |
+
$env:VC_VLLM_EXTRA_INDEX_URL="https://wheels.vllm.ai/nightly/cu129"
|
| 78 |
+
$env:VC_VLLM_UV_EXTRA_OPTIONS="--index-strategy unsafe-best-match"
|
| 79 |
+
$env:VC_VLLM_PRE="1"
|
| 80 |
+
$env:VC_VLLM_GPU="L40S"
|
| 81 |
+
$env:VC_VLLM_FAST_BOOT="1"
|
| 82 |
+
modal deploy modal_apps/modal_vllm_gemma.py
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
Keep the deployed vLLM endpoint warm:
|
| 86 |
+
|
| 87 |
+
```powershell
|
| 88 |
+
python scripts/set_modal_vllm_autoscaler.py on
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
This updates the deployed `serve` function to `min_containers=1`, `buffer_containers=0`, and `scaledown_window=1200`. It takes effect without rebuilding the image, but Modal resets this override on the next deploy.
|
| 92 |
+
|
| 93 |
+
To make warm residency part of the deployment configuration, set these before `modal deploy`:
|
| 94 |
+
|
| 95 |
+
```powershell
|
| 96 |
+
$env:VC_VLLM_MIN_CONTAINERS="1"
|
| 97 |
+
$env:VC_VLLM_BUFFER_CONTAINERS="0"
|
| 98 |
+
$env:VC_VLLM_SCALEDOWN_WINDOW="1200"
|
| 99 |
+
modal deploy modal_apps/modal_vllm_gemma.py
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Turn warm residency off when the demo window is over:
|
| 103 |
+
|
| 104 |
+
```powershell
|
| 105 |
+
python scripts/set_modal_vllm_autoscaler.py off
|
| 106 |
+
```
|
| 107 |
+
|
| 108 |
+
Current deployed endpoint:
|
| 109 |
+
|
| 110 |
+
```text
|
| 111 |
+
https://veronicaulises0--virtual-characters-vllm-gemma-serve.modal.run
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
This deployment intentionally skips mounting `hf-token` into the nightly vLLM runtime. It depends on the `vc-hf-cache` Modal Volume already containing `google/gemma-4-12B-it`.
|
| 115 |
+
|
| 116 |
+
To avoid building every service image at once, check one service at a time:
|
| 117 |
+
|
| 118 |
+
```powershell
|
| 119 |
+
python scripts/check_modal_connectivity.py --mode remote-methods --service llm
|
| 120 |
+
python scripts/check_modal_connectivity.py --mode remote-methods --service tts
|
| 121 |
+
python scripts/check_modal_connectivity.py --mode remote-methods --service image
|
| 122 |
+
```
|
| 123 |
+
|
| 124 |
+
## Character generation spike
|
| 125 |
+
|
| 126 |
+
The automated character-generation spike is intentionally isolated from the Gradio UI and the deployed image endpoint.
|
| 127 |
+
|
| 128 |
+
Safe checks:
|
| 129 |
+
|
| 130 |
+
```powershell
|
| 131 |
+
python scripts/run_character_generation_spike.py list-models
|
| 132 |
+
python scripts/run_character_generation_spike.py modal-health
|
| 133 |
+
```
|
| 134 |
+
|
| 135 |
+
`modal-health` starts the Modal app but does not load model weights. Generation probes load weights and consume GPU credits, so the CLI requires `--confirm-gpu`:
|
| 136 |
+
|
| 137 |
+
```powershell
|
| 138 |
+
python scripts/run_character_generation_spike.py modal-probe --candidate flux_schnell --batch-size 1 --confirm-gpu
|
| 139 |
+
python scripts/run_character_generation_spike.py modal-benchmark --candidates flux_schnell qwen_image --confirm-gpu
|
| 140 |
+
python scripts/run_character_generation_spike.py modal-benchmark --candidates qwen_image_edit --init-image path\to\reference.png --include-expressions --confirm-gpu
|
| 141 |
+
python scripts/run_character_generation_spike.py modal-benchmark --candidates qwen_controlnet_union --control-image path\to\pose.png --include-expressions --confirm-gpu
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Candidates:
|
| 145 |
+
|
| 146 |
+
- `flux_schnell`: speed baseline using `black-forest-labs/FLUX.1-schnell`.
|
| 147 |
+
- `qwen_image`: Chinese prompt and high-quality text-to-image candidate using `Qwen/Qwen-Image`.
|
| 148 |
+
- `qwen_image_edit`: expression/edit candidate using `Qwen/Qwen-Image-Edit`.
|
| 149 |
+
- `qwen_controlnet_union`: pose/canny/depth action candidate using `InstantX/Qwen-Image-ControlNet-Union`.
|
| 150 |
+
- `instantid_sdxl`: tracked as identity-preserving candidate, but disabled until the face-analysis/model download path is pinned.
|
| 151 |
+
|
| 152 |
+
The spike image installs diffusers from GitHub because the Qwen Image and ControlNet pipelines require recent upstream support. If a model is gated or not cached, make sure the `hf-token` secret has accepted the Hugging Face model terms.
|
| 153 |
+
|
| 154 |
+
If `hf-token` is not created yet and you only want to verify Modal container startup, use:
|
| 155 |
+
|
| 156 |
+
```powershell
|
| 157 |
+
$env:VC_SKIP_HF_SECRET="1"
|
| 158 |
+
python scripts/check_modal_connectivity.py --mode remote-methods
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
Only deploy with `VC_SKIP_HF_SECRET=1` when the selected model is public or the required weights are already cached in the mounted Modal Volume. The working vLLM Gemma 4 deployment uses this cache-based route to avoid exposing `hf-token` to a nightly dependency stack.
|
| 162 |
+
|
| 163 |
+
Deploy services:
|
| 164 |
+
|
| 165 |
+
```powershell
|
| 166 |
+
modal deploy modal_apps/modal_llm.py
|
| 167 |
+
modal deploy modal_apps/modal_tts.py
|
| 168 |
+
modal deploy modal_apps/modal_image.py
|
| 169 |
+
```
|
| 170 |
+
|
| 171 |
+
Then set endpoint URLs from Modal output:
|
| 172 |
+
|
| 173 |
+
```powershell
|
| 174 |
+
$env:VC_MODAL_LLM_URL="https://...modal.run/persona_events"
|
| 175 |
+
$env:VC_MODAL_TTS_URL="https://...modal.run/tts"
|
| 176 |
+
$env:VC_MODAL_IMAGE_URL="https://...modal.run/character_image"
|
| 177 |
+
python scripts/check_modal_connectivity.py --mode endpoints
|
| 178 |
+
```
|
| 179 |
+
|
| 180 |
+
## Cost defaults
|
| 181 |
+
|
| 182 |
+
- vLLM LLM: `google/gemma-4-12B-it`, GPU `L40S`, scaledown 5 min unless `VC_VLLM_MIN_CONTAINERS=1`.
|
| 183 |
+
- Transformers LLM fallback: `google/gemma-4-12B-it`, GPU `L40S`, scaledown 3 min.
|
| 184 |
+
- TTS: Chatterbox, GPU `A10G`, scaledown 3 min.
|
| 185 |
+
- Image: FLUX.1-schnell, GPU `H100`, scaledown 1 min.
|
| 186 |
+
|
| 187 |
+
Modal public GPU prices checked on 2026-06-14:
|
| 188 |
+
|
| 189 |
+
| GPU | Approx hourly | Approx 7 days |
|
| 190 |
+
| --- | ---: | ---: |
|
| 191 |
+
| T4 | $0.5904 | $99.19 |
|
| 192 |
+
| L4 | $0.7992 | $134.27 |
|
| 193 |
+
| A10 | $1.1016 | $185.07 |
|
| 194 |
+
| L40S | $1.9512 | $327.80 |
|
| 195 |
+
| A100-40GB | $2.0988 | $352.60 |
|
| 196 |
+
| A100-80GB | $2.4984 | $419.73 |
|
| 197 |
+
| H100 | $3.9492 | $663.47 |
|
| 198 |
+
|
| 199 |
+
These numbers are GPU-only. CPU, memory, regional multipliers, non-preemptible execution, and storage can add extra cost. With a $240 budget, one L40S can stay warm for about 123 hours, so a full 7-day L40S warm deployment is over budget. A10 fits the 7-day GPU-only budget, but the current Gemma 4 12B vLLM deployment is validated on L40S and should not be moved to A10 without a separate benchmark.
|
| 200 |
+
|
| 201 |
+
Override with env vars before deploy:
|
| 202 |
+
|
| 203 |
+
```powershell
|
| 204 |
+
$env:VC_LLM_MODEL="google/gemma-4-E4B-it"
|
| 205 |
+
$env:VC_LLM_GPU="A10"
|
| 206 |
+
$env:VC_TTS_BACKEND="kokoro"
|
| 207 |
+
$env:VC_IMAGE_GPU="L40S"
|
| 208 |
+
```
|
requirements.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio[oauth]>=6.0.0
|
| 2 |
+
httpx>=0.28.0
|
| 3 |
+
modal>=1.0.0
|
| 4 |
+
pillow>=10.0.0
|
| 5 |
+
python-dotenv>=1.0.1
|
| 6 |
+
rembg[cpu]>=2.0.67
|
src/character_spike/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Risk-validation helpers for automated character generation."""
|
| 2 |
+
|
src/character_spike/assets.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
import random
|
| 6 |
+
import time
|
| 7 |
+
from collections import deque
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
| 12 |
+
|
| 13 |
+
from src.character_spike.schema import CANONICAL_STAGE_SIZE, EXPRESSIONS, default_character_package
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def create_asset_package_from_probe_outputs(
|
| 17 |
+
*,
|
| 18 |
+
source_run_dir: str | Path,
|
| 19 |
+
candidate_id: str,
|
| 20 |
+
character_id: str,
|
| 21 |
+
display_name: str,
|
| 22 |
+
output_root: str | Path,
|
| 23 |
+
seed: int = 42,
|
| 24 |
+
remove_background: bool = True,
|
| 25 |
+
) -> dict[str, Any]:
|
| 26 |
+
started = time.perf_counter()
|
| 27 |
+
source = Path(source_run_dir)
|
| 28 |
+
out_root = Path(output_root)
|
| 29 |
+
run_dir = out_root / character_id
|
| 30 |
+
character_dir = run_dir / "assets" / "characters" / character_id
|
| 31 |
+
background_dir = run_dir / "assets" / "backgrounds"
|
| 32 |
+
generated_dir = run_dir / "generated"
|
| 33 |
+
character_dir.mkdir(parents=True, exist_ok=True)
|
| 34 |
+
background_dir.mkdir(parents=True, exist_ok=True)
|
| 35 |
+
generated_dir.mkdir(parents=True, exist_ok=True)
|
| 36 |
+
|
| 37 |
+
package = default_character_package(character_id, display_name)
|
| 38 |
+
package["metadata"]["source"] = "probe_asset_package"
|
| 39 |
+
package["metadata"]["source_run_dir"] = str(source)
|
| 40 |
+
package["metadata"]["candidate_id"] = candidate_id
|
| 41 |
+
package_path = run_dir / "characters" / f"{character_id}.json"
|
| 42 |
+
package_path.parent.mkdir(parents=True, exist_ok=True)
|
| 43 |
+
package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 44 |
+
|
| 45 |
+
assets: list[dict[str, Any]] = []
|
| 46 |
+
missing_slots: list[str] = []
|
| 47 |
+
for expression in EXPRESSIONS:
|
| 48 |
+
source_path = source / "generated" / candidate_id / f"expression_{expression}" / "00.png"
|
| 49 |
+
if not source_path.exists():
|
| 50 |
+
source_path = source / "generated" / candidate_id / "expression_idle" / "00.png"
|
| 51 |
+
missing_slots.append(expression)
|
| 52 |
+
target_path = character_dir / f"{expression}.png"
|
| 53 |
+
image = Image.open(source_path).convert("RGBA")
|
| 54 |
+
processed = remove_flat_background(image) if remove_background else image
|
| 55 |
+
normalized = normalize_to_stage_canvas(processed)
|
| 56 |
+
normalized.save(target_path)
|
| 57 |
+
assets.append(
|
| 58 |
+
{
|
| 59 |
+
"slot": expression,
|
| 60 |
+
"path": str(target_path.relative_to(run_dir)),
|
| 61 |
+
"source_path": str(source_path),
|
| 62 |
+
"bytes": target_path.stat().st_size,
|
| 63 |
+
"source": f"probe:{candidate_id}",
|
| 64 |
+
"usable": expression not in missing_slots,
|
| 65 |
+
"manual_score": None,
|
| 66 |
+
}
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
background_path = background_dir / f"{character_id}_spike_background.png"
|
| 70 |
+
_draw_mock_background(display_name, random.Random(seed)).save(background_path)
|
| 71 |
+
grid_path = generated_dir / "asset_grid.png"
|
| 72 |
+
make_thumbnail_grid([character_dir / f"{slot}.png" for slot in EXPRESSIONS], grid_path)
|
| 73 |
+
|
| 74 |
+
manifest = {
|
| 75 |
+
"schema_version": 1,
|
| 76 |
+
"run_type": "probe_asset_package",
|
| 77 |
+
"character_id": character_id,
|
| 78 |
+
"display_name": display_name,
|
| 79 |
+
"seed": seed,
|
| 80 |
+
"created_at_unix": int(time.time()),
|
| 81 |
+
"duration_seconds": round(time.perf_counter() - started, 3),
|
| 82 |
+
"paths": {
|
| 83 |
+
"run_dir": str(run_dir),
|
| 84 |
+
"character_package": str(package_path.relative_to(run_dir)),
|
| 85 |
+
"character_assets": str(character_dir.relative_to(run_dir)),
|
| 86 |
+
"background": str(background_path.relative_to(run_dir)),
|
| 87 |
+
"thumbnail_grid": str(grid_path.relative_to(run_dir)),
|
| 88 |
+
},
|
| 89 |
+
"assets": assets,
|
| 90 |
+
"model_results": [],
|
| 91 |
+
"qa": {
|
| 92 |
+
"usable_assets": len([asset for asset in assets if asset["usable"]]),
|
| 93 |
+
"total_assets": len(EXPRESSIONS),
|
| 94 |
+
"needs_manual_review": missing_slots,
|
| 95 |
+
"notes": [
|
| 96 |
+
"Packaged from Modal probe outputs.",
|
| 97 |
+
"Background removal uses a simple flat-background alpha pass; manual QA is still required."
|
| 98 |
+
if remove_background
|
| 99 |
+
else "Background was intentionally preserved as a no-matting fallback.",
|
| 100 |
+
],
|
| 101 |
+
},
|
| 102 |
+
}
|
| 103 |
+
manifest_path = generated_dir / "manifest.json"
|
| 104 |
+
manifest["paths"]["manifest"] = str(manifest_path.relative_to(run_dir))
|
| 105 |
+
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 106 |
+
write_report(manifest, generated_dir / "report.md")
|
| 107 |
+
return manifest
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def create_mock_asset_package(
|
| 111 |
+
*,
|
| 112 |
+
character_id: str,
|
| 113 |
+
display_name: str,
|
| 114 |
+
output_root: str | Path,
|
| 115 |
+
seed: int = 42,
|
| 116 |
+
) -> dict[str, Any]:
|
| 117 |
+
started = time.perf_counter()
|
| 118 |
+
rng = random.Random(seed)
|
| 119 |
+
out_root = Path(output_root)
|
| 120 |
+
run_dir = out_root / character_id
|
| 121 |
+
character_dir = run_dir / "assets" / "characters" / character_id
|
| 122 |
+
background_dir = run_dir / "assets" / "backgrounds"
|
| 123 |
+
generated_dir = run_dir / "generated"
|
| 124 |
+
character_dir.mkdir(parents=True, exist_ok=True)
|
| 125 |
+
background_dir.mkdir(parents=True, exist_ok=True)
|
| 126 |
+
generated_dir.mkdir(parents=True, exist_ok=True)
|
| 127 |
+
|
| 128 |
+
package = default_character_package(character_id, display_name)
|
| 129 |
+
package["metadata"]["source"] = "mock_asset_package"
|
| 130 |
+
package_path = run_dir / "characters" / f"{character_id}.json"
|
| 131 |
+
package_path.parent.mkdir(parents=True, exist_ok=True)
|
| 132 |
+
package_path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 133 |
+
|
| 134 |
+
assets: list[dict[str, Any]] = []
|
| 135 |
+
for index, expression in enumerate(EXPRESSIONS):
|
| 136 |
+
path = character_dir / f"{expression}.png"
|
| 137 |
+
image = _draw_mock_character(display_name, expression, index, rng)
|
| 138 |
+
normalized = normalize_to_stage_canvas(image)
|
| 139 |
+
normalized.save(path)
|
| 140 |
+
assets.append(
|
| 141 |
+
{
|
| 142 |
+
"slot": expression,
|
| 143 |
+
"path": str(path.relative_to(run_dir)),
|
| 144 |
+
"bytes": path.stat().st_size,
|
| 145 |
+
"source": "mock_pillow",
|
| 146 |
+
"usable": True,
|
| 147 |
+
"manual_score": None,
|
| 148 |
+
}
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
background_path = background_dir / f"{character_id}_spike_background.png"
|
| 152 |
+
_draw_mock_background(display_name, rng).save(background_path)
|
| 153 |
+
grid_path = generated_dir / "asset_grid.png"
|
| 154 |
+
make_thumbnail_grid([character_dir / f"{slot}.png" for slot in EXPRESSIONS], grid_path)
|
| 155 |
+
|
| 156 |
+
manifest = {
|
| 157 |
+
"schema_version": 1,
|
| 158 |
+
"run_type": "mock_asset_package",
|
| 159 |
+
"character_id": character_id,
|
| 160 |
+
"display_name": display_name,
|
| 161 |
+
"seed": seed,
|
| 162 |
+
"created_at_unix": int(time.time()),
|
| 163 |
+
"duration_seconds": round(time.perf_counter() - started, 3),
|
| 164 |
+
"paths": {
|
| 165 |
+
"run_dir": str(run_dir),
|
| 166 |
+
"character_package": str(package_path.relative_to(run_dir)),
|
| 167 |
+
"character_assets": str(character_dir.relative_to(run_dir)),
|
| 168 |
+
"background": str(background_path.relative_to(run_dir)),
|
| 169 |
+
"thumbnail_grid": str(grid_path.relative_to(run_dir)),
|
| 170 |
+
},
|
| 171 |
+
"assets": assets,
|
| 172 |
+
"model_results": [],
|
| 173 |
+
"qa": {
|
| 174 |
+
"usable_assets": len(assets),
|
| 175 |
+
"total_assets": len(EXPRESSIONS),
|
| 176 |
+
"needs_manual_review": [],
|
| 177 |
+
"notes": ["Mock assets validate packaging, postprocessing, manifest, and reporting without GPU cost."],
|
| 178 |
+
},
|
| 179 |
+
}
|
| 180 |
+
manifest_path = generated_dir / "manifest.json"
|
| 181 |
+
manifest["paths"]["manifest"] = str(manifest_path.relative_to(run_dir))
|
| 182 |
+
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 183 |
+
write_report(manifest, generated_dir / "report.md")
|
| 184 |
+
return manifest
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def normalize_to_stage_canvas(image: Image.Image, size: tuple[int, int] = CANONICAL_STAGE_SIZE) -> Image.Image:
|
| 188 |
+
image = image.convert("RGBA")
|
| 189 |
+
alpha = image.getchannel("A")
|
| 190 |
+
bbox = alpha.getbbox()
|
| 191 |
+
if bbox is None:
|
| 192 |
+
return Image.new("RGBA", size, (0, 0, 0, 0))
|
| 193 |
+
|
| 194 |
+
crop = image.crop(bbox)
|
| 195 |
+
max_width = int(size[0] * 0.82)
|
| 196 |
+
max_height = int(size[1] * 0.92)
|
| 197 |
+
scale = min(max_width / crop.width, max_height / crop.height)
|
| 198 |
+
new_size = (max(1, round(crop.width * scale)), max(1, round(crop.height * scale)))
|
| 199 |
+
crop = crop.resize(new_size, Image.Resampling.LANCZOS)
|
| 200 |
+
canvas = Image.new("RGBA", size, (0, 0, 0, 0))
|
| 201 |
+
x = (size[0] - new_size[0]) // 2
|
| 202 |
+
y = size[1] - new_size[1] - 24
|
| 203 |
+
canvas.alpha_composite(crop, (x, y))
|
| 204 |
+
return canvas
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def remove_flat_background(
|
| 208 |
+
image: Image.Image,
|
| 209 |
+
*,
|
| 210 |
+
tolerance: int = 42,
|
| 211 |
+
feather_radius: float = 1.25,
|
| 212 |
+
) -> Image.Image:
|
| 213 |
+
image = image.convert("RGBA")
|
| 214 |
+
width, height = image.size
|
| 215 |
+
sample_points = [
|
| 216 |
+
(0, 0),
|
| 217 |
+
(width - 1, 0),
|
| 218 |
+
(0, height - 1),
|
| 219 |
+
(width - 1, height - 1),
|
| 220 |
+
(width // 2, 0),
|
| 221 |
+
(width // 2, height - 1),
|
| 222 |
+
]
|
| 223 |
+
pixels = image.load()
|
| 224 |
+
samples = [pixels[x, y][:3] for x, y in sample_points]
|
| 225 |
+
background = tuple(round(sum(channel) / len(samples)) for channel in zip(*samples))
|
| 226 |
+
|
| 227 |
+
source_pixels = image.load()
|
| 228 |
+
visited = bytearray(width * height)
|
| 229 |
+
queue: deque[tuple[int, int]] = deque()
|
| 230 |
+
|
| 231 |
+
def is_background(x: int, y: int) -> bool:
|
| 232 |
+
rgb = source_pixels[x, y][:3]
|
| 233 |
+
distance = max(abs(rgb[index] - background[index]) for index in range(3))
|
| 234 |
+
return distance <= tolerance
|
| 235 |
+
|
| 236 |
+
def push(x: int, y: int) -> None:
|
| 237 |
+
index = y * width + x
|
| 238 |
+
if visited[index] or not is_background(x, y):
|
| 239 |
+
return
|
| 240 |
+
visited[index] = 1
|
| 241 |
+
queue.append((x, y))
|
| 242 |
+
|
| 243 |
+
for x in range(width):
|
| 244 |
+
push(x, 0)
|
| 245 |
+
push(x, height - 1)
|
| 246 |
+
for y in range(height):
|
| 247 |
+
push(0, y)
|
| 248 |
+
push(width - 1, y)
|
| 249 |
+
|
| 250 |
+
while queue:
|
| 251 |
+
x, y = queue.popleft()
|
| 252 |
+
if x > 0:
|
| 253 |
+
push(x - 1, y)
|
| 254 |
+
if x < width - 1:
|
| 255 |
+
push(x + 1, y)
|
| 256 |
+
if y > 0:
|
| 257 |
+
push(x, y - 1)
|
| 258 |
+
if y < height - 1:
|
| 259 |
+
push(x, y + 1)
|
| 260 |
+
|
| 261 |
+
alpha = Image.new("L", image.size, 255)
|
| 262 |
+
alpha_pixels = alpha.load()
|
| 263 |
+
for y in range(height):
|
| 264 |
+
row = y * width
|
| 265 |
+
for x in range(width):
|
| 266 |
+
if visited[row + x]:
|
| 267 |
+
alpha_pixels[x, y] = 0
|
| 268 |
+
if feather_radius > 0:
|
| 269 |
+
alpha = alpha.filter(ImageFilter.GaussianBlur(feather_radius))
|
| 270 |
+
image.putalpha(alpha)
|
| 271 |
+
return image
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
def make_thumbnail_grid(paths: list[Path], output_path: str | Path, thumb_size: tuple[int, int] = (180, 240)) -> Path:
|
| 275 |
+
output = Path(output_path)
|
| 276 |
+
cols = 4
|
| 277 |
+
rows = math.ceil(len(paths) / cols)
|
| 278 |
+
grid = Image.new("RGB", (cols * thumb_size[0], rows * (thumb_size[1] + 26)), (17, 24, 39))
|
| 279 |
+
draw = ImageDraw.Draw(grid)
|
| 280 |
+
font = _font(16)
|
| 281 |
+
for index, path in enumerate(paths):
|
| 282 |
+
row, col = divmod(index, cols)
|
| 283 |
+
x = col * thumb_size[0]
|
| 284 |
+
y = row * (thumb_size[1] + 26)
|
| 285 |
+
image = Image.open(path).convert("RGBA")
|
| 286 |
+
image.thumbnail(thumb_size, Image.Resampling.LANCZOS)
|
| 287 |
+
cell = Image.new("RGBA", thumb_size, (15, 23, 42, 255))
|
| 288 |
+
px = (thumb_size[0] - image.width) // 2
|
| 289 |
+
py = (thumb_size[1] - image.height) // 2
|
| 290 |
+
cell.alpha_composite(image, (px, py))
|
| 291 |
+
grid.paste(cell.convert("RGB"), (x, y))
|
| 292 |
+
draw.text((x + 8, y + thumb_size[1] + 4), path.stem, fill=(226, 232, 240), font=font)
|
| 293 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 294 |
+
grid.save(output)
|
| 295 |
+
return output
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def write_report(manifest: dict[str, Any], path: str | Path) -> Path:
|
| 299 |
+
output = Path(path)
|
| 300 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 301 |
+
report = render_report_markdown(manifest)
|
| 302 |
+
output.write_text(report, encoding="utf-8")
|
| 303 |
+
return output
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def render_report_markdown(manifest: dict[str, Any]) -> str:
|
| 307 |
+
qa = manifest.get("qa", {})
|
| 308 |
+
model_results = manifest.get("model_results") or []
|
| 309 |
+
assets = manifest.get("assets") or []
|
| 310 |
+
lines = [
|
| 311 |
+
f"# Character Generation Spike Report: {manifest.get('display_name', manifest.get('character_id', 'unknown'))}",
|
| 312 |
+
"",
|
| 313 |
+
"## Run",
|
| 314 |
+
"",
|
| 315 |
+
f"- Character ID: `{manifest.get('character_id')}`",
|
| 316 |
+
f"- Run type: `{manifest.get('run_type')}`",
|
| 317 |
+
f"- Duration: `{manifest.get('duration_seconds')}` seconds",
|
| 318 |
+
f"- Seed: `{manifest.get('seed')}`",
|
| 319 |
+
"",
|
| 320 |
+
"## Asset QA",
|
| 321 |
+
"",
|
| 322 |
+
f"- Usable assets: `{qa.get('usable_assets', 0)}/{qa.get('total_assets', len(assets))}`",
|
| 323 |
+
f"- Needs manual review: `{len(qa.get('needs_manual_review') or [])}`",
|
| 324 |
+
]
|
| 325 |
+
for note in qa.get("notes") or []:
|
| 326 |
+
lines.append(f"- Note: {note}")
|
| 327 |
+
lines.extend(["", "## Model Results", ""])
|
| 328 |
+
if model_results:
|
| 329 |
+
lines.append("| Candidate | Mode | Cold/Warm | Images | Seconds | GPU | Status |")
|
| 330 |
+
lines.append("| --- | --- | --- | ---: | ---: | --- | --- |")
|
| 331 |
+
for result in model_results:
|
| 332 |
+
lines.append(
|
| 333 |
+
"| {candidate} | {mode} | {temperature} | {images} | {seconds} | {gpu} | {status} |".format(
|
| 334 |
+
candidate=result.get("candidate_id", ""),
|
| 335 |
+
mode=result.get("mode", ""),
|
| 336 |
+
temperature="cold" if result.get("loaded_before") is False else "warm",
|
| 337 |
+
images=result.get("image_count", 0),
|
| 338 |
+
seconds=result.get("duration_seconds", ""),
|
| 339 |
+
gpu=result.get("gpu", ""),
|
| 340 |
+
status=result.get("status", ""),
|
| 341 |
+
)
|
| 342 |
+
)
|
| 343 |
+
else:
|
| 344 |
+
lines.append("No remote model probes recorded yet.")
|
| 345 |
+
|
| 346 |
+
lines.extend(["", "## Assets", ""])
|
| 347 |
+
for asset in assets:
|
| 348 |
+
lines.append(f"- `{asset.get('slot')}`: `{asset.get('path')}` ({asset.get('bytes')} bytes)")
|
| 349 |
+
lines.append("")
|
| 350 |
+
return "\n".join(lines)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def _draw_mock_character(display_name: str, expression: str, index: int, rng: random.Random) -> Image.Image:
|
| 354 |
+
width, height = CANONICAL_STAGE_SIZE
|
| 355 |
+
image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
| 356 |
+
draw = ImageDraw.Draw(image)
|
| 357 |
+
accent = _palette(index)
|
| 358 |
+
skin = (255, 216, 204, 255)
|
| 359 |
+
coat = (31 + rng.randrange(20), 41 + rng.randrange(20), 55 + rng.randrange(30), 255)
|
| 360 |
+
hair = (190 + rng.randrange(45), 230 + rng.randrange(20), 245 + rng.randrange(10), 255)
|
| 361 |
+
|
| 362 |
+
draw.ellipse((265, 970, 635, 1060), fill=(0, 0, 0, 72))
|
| 363 |
+
draw.polygon([(250, 1080), (345, 660), (555, 660), (650, 1080)], fill=coat)
|
| 364 |
+
draw.polygon([(345, 670), (450, 850), (555, 670), (595, 1080), (305, 1080)], fill=(12, 18, 31, 255))
|
| 365 |
+
draw.rounded_rectangle((395, 600, 505, 730), radius=36, fill=(239, 184, 170, 255))
|
| 366 |
+
draw.ellipse((255, 170, 645, 640), fill=skin)
|
| 367 |
+
draw.ellipse((210, 95, 690, 520), fill=hair)
|
| 368 |
+
draw.pieslice((210, 110, 690, 650), 185, 355, fill=(120, 190, 210, 255))
|
| 369 |
+
draw.polygon([(300, 250), (370, 100), (420, 340)], fill=hair)
|
| 370 |
+
draw.polygon([(430, 230), (510, 80), (540, 345)], fill=hair)
|
| 371 |
+
draw.polygon([(535, 260), (610, 150), (625, 390)], fill=hair)
|
| 372 |
+
draw.rounded_rectangle((385, 795, 515, 835), radius=18, fill=accent)
|
| 373 |
+
|
| 374 |
+
_draw_expression(draw, expression, accent)
|
| 375 |
+
font = _font(28)
|
| 376 |
+
small = _font(22)
|
| 377 |
+
draw.text((34, 34), display_name, fill=(238, 242, 255, 220), font=font)
|
| 378 |
+
draw.text((34, 72), expression, fill=accent, font=small)
|
| 379 |
+
return image
|
| 380 |
+
|
| 381 |
+
|
| 382 |
+
def _draw_expression(draw: ImageDraw.ImageDraw, expression: str, accent: tuple[int, int, int, int]) -> None:
|
| 383 |
+
eye = (15, 23, 42, 255)
|
| 384 |
+
if expression in {"smile", "happy"}:
|
| 385 |
+
draw.arc((325, 385, 410, 445), 200, 340, fill=eye, width=8)
|
| 386 |
+
draw.arc((490, 385, 575, 445), 200, 340, fill=eye, width=8)
|
| 387 |
+
draw.arc((405, 505, 495, 570), 15, 165, fill=(159, 18, 57, 255), width=8)
|
| 388 |
+
else:
|
| 389 |
+
draw.ellipse((330, 380, 405, 455), fill=(248, 250, 252, 255))
|
| 390 |
+
draw.ellipse((495, 380, 570, 455), fill=(248, 250, 252, 255))
|
| 391 |
+
draw.ellipse((356, 398, 388, 438), fill=accent)
|
| 392 |
+
draw.ellipse((521, 398, 553, 438), fill=accent)
|
| 393 |
+
draw.ellipse((366, 410, 382, 433), fill=eye)
|
| 394 |
+
draw.ellipse((531, 410, 547, 433), fill=eye)
|
| 395 |
+
if expression == "worried":
|
| 396 |
+
draw.arc((410, 525, 490, 580), 200, 340, fill=(159, 18, 57, 255), width=8)
|
| 397 |
+
else:
|
| 398 |
+
draw.arc((410, 505, 490, 552), 25, 155, fill=(159, 18, 57, 255), width=7)
|
| 399 |
+
if expression == "thinking":
|
| 400 |
+
draw.line((315, 350, 405, 360), fill=eye, width=7)
|
| 401 |
+
draw.line((495, 360, 585, 350), fill=eye, width=7)
|
| 402 |
+
elif expression == "worried":
|
| 403 |
+
draw.line((315, 365, 405, 340), fill=eye, width=7)
|
| 404 |
+
draw.line((495, 340, 585, 365), fill=eye, width=7)
|
| 405 |
+
else:
|
| 406 |
+
draw.line((320, 360, 405, 350), fill=eye, width=7)
|
| 407 |
+
draw.line((495, 350, 580, 360), fill=eye, width=7)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
def _draw_mock_background(display_name: str, rng: random.Random) -> Image.Image:
|
| 411 |
+
width, height = 1600, 900
|
| 412 |
+
image = Image.new("RGB", (width, height), (12, 18, 31))
|
| 413 |
+
draw = ImageDraw.Draw(image)
|
| 414 |
+
for y in range(height):
|
| 415 |
+
shade = int(20 + 40 * y / height)
|
| 416 |
+
draw.line((0, y, width, y), fill=(10, 16 + shade // 4, 26 + shade))
|
| 417 |
+
for _ in range(70):
|
| 418 |
+
x = rng.randrange(width)
|
| 419 |
+
y = rng.randrange(height)
|
| 420 |
+
radius = rng.randrange(1, 4)
|
| 421 |
+
draw.ellipse((x, y, x + radius, y + radius), fill=(148, 163, 184))
|
| 422 |
+
for x in range(0, width, 120):
|
| 423 |
+
draw.line((x, 0, x + 320, height), fill=(255, 255, 255, 16), width=1)
|
| 424 |
+
font = _font(44)
|
| 425 |
+
draw.text((56, 56), f"{display_name} / spike background", fill=(226, 232, 240), font=font)
|
| 426 |
+
return image
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _palette(index: int) -> tuple[int, int, int, int]:
|
| 430 |
+
colors = [
|
| 431 |
+
(103, 232, 249, 255),
|
| 432 |
+
(125, 211, 252, 255),
|
| 433 |
+
(250, 204, 21, 255),
|
| 434 |
+
(244, 114, 182, 255),
|
| 435 |
+
(167, 243, 208, 255),
|
| 436 |
+
(196, 181, 253, 255),
|
| 437 |
+
(251, 146, 60, 255),
|
| 438 |
+
(129, 140, 248, 255),
|
| 439 |
+
]
|
| 440 |
+
return colors[index % len(colors)]
|
| 441 |
+
|
| 442 |
+
|
| 443 |
+
def _font(size: int) -> ImageFont.ImageFont:
|
| 444 |
+
try:
|
| 445 |
+
return ImageFont.truetype("arial.ttf", size)
|
| 446 |
+
except OSError:
|
| 447 |
+
return ImageFont.load_default()
|
src/character_spike/schema.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
EXPRESSIONS = ["idle", "listening", "thinking", "worried", "smile", "happy", "talk", "focus"]
|
| 10 |
+
CANONICAL_STAGE_SIZE = (900, 1200)
|
| 11 |
+
WARM_FOUR_IMAGE_LIMIT_SECONDS = 60.0
|
| 12 |
+
EIGHT_ASSET_LIMIT_SECONDS = 180.0
|
| 13 |
+
MIN_USABLE_ASSET_COUNT = 6
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class ModelCandidate:
|
| 18 |
+
id: str
|
| 19 |
+
label: str
|
| 20 |
+
family: str
|
| 21 |
+
model_id: str
|
| 22 |
+
mode: str
|
| 23 |
+
default_steps: int
|
| 24 |
+
default_gpu: str
|
| 25 |
+
implemented: bool
|
| 26 |
+
notes: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
MODEL_CANDIDATES: tuple[ModelCandidate, ...] = (
|
| 30 |
+
ModelCandidate(
|
| 31 |
+
id="flux_schnell",
|
| 32 |
+
label="FLUX.1-schnell",
|
| 33 |
+
family="flux",
|
| 34 |
+
model_id="black-forest-labs/FLUX.1-schnell",
|
| 35 |
+
mode="text_to_image_speed_baseline",
|
| 36 |
+
default_steps=4,
|
| 37 |
+
default_gpu="H100",
|
| 38 |
+
implemented=True,
|
| 39 |
+
notes="Speed baseline for main visual candidates.",
|
| 40 |
+
),
|
| 41 |
+
ModelCandidate(
|
| 42 |
+
id="qwen_image",
|
| 43 |
+
label="Qwen-Image",
|
| 44 |
+
family="qwen_image",
|
| 45 |
+
model_id="Qwen/Qwen-Image",
|
| 46 |
+
mode="text_to_image_quality_candidate",
|
| 47 |
+
default_steps=50,
|
| 48 |
+
default_gpu="H100",
|
| 49 |
+
implemented=True,
|
| 50 |
+
notes="Quality and Chinese prompt candidate; likely slower than FLUX.",
|
| 51 |
+
),
|
| 52 |
+
ModelCandidate(
|
| 53 |
+
id="qwen_image_edit",
|
| 54 |
+
label="Qwen-Image-Edit",
|
| 55 |
+
family="qwen_image_edit",
|
| 56 |
+
model_id="Qwen/Qwen-Image-Edit",
|
| 57 |
+
mode="instruction_image_edit",
|
| 58 |
+
default_steps=50,
|
| 59 |
+
default_gpu="H100",
|
| 60 |
+
implemented=True,
|
| 61 |
+
notes="Expression and local edit candidate based on a reference image.",
|
| 62 |
+
),
|
| 63 |
+
ModelCandidate(
|
| 64 |
+
id="qwen_controlnet_union",
|
| 65 |
+
label="Qwen-Image-ControlNet-Union",
|
| 66 |
+
family="qwen_controlnet",
|
| 67 |
+
model_id="InstantX/Qwen-Image-ControlNet-Union",
|
| 68 |
+
mode="pose_canny_depth_control",
|
| 69 |
+
default_steps=30,
|
| 70 |
+
default_gpu="H100",
|
| 71 |
+
implemented=True,
|
| 72 |
+
notes="Structure control candidate for action poses.",
|
| 73 |
+
),
|
| 74 |
+
ModelCandidate(
|
| 75 |
+
id="instantid_sdxl",
|
| 76 |
+
label="InstantID SDXL",
|
| 77 |
+
family="instantid",
|
| 78 |
+
model_id="InstantX/InstantID",
|
| 79 |
+
mode="identity_preserving_candidate",
|
| 80 |
+
default_steps=30,
|
| 81 |
+
default_gpu="H100",
|
| 82 |
+
implemented=False,
|
| 83 |
+
notes="Tracked as identity-preserving candidate; remote runner is intentionally not enabled until antelopev2/model download path is decided.",
|
| 84 |
+
),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def candidate_by_id(candidate_id: str) -> ModelCandidate:
|
| 89 |
+
for candidate in MODEL_CANDIDATES:
|
| 90 |
+
if candidate.id == candidate_id:
|
| 91 |
+
return candidate
|
| 92 |
+
known = ", ".join(candidate.id for candidate in MODEL_CANDIDATES)
|
| 93 |
+
raise ValueError(f"unknown model candidate: {candidate_id}; expected one of {known}")
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def slugify_identifier(value: str, fallback: str = "character") -> str:
|
| 97 |
+
normalized = value.strip().lower()
|
| 98 |
+
normalized = re.sub(r"[^a-z0-9_\-\u4e00-\u9fff]+", "_", normalized)
|
| 99 |
+
normalized = re.sub(r"_+", "_", normalized).strip("_-")
|
| 100 |
+
return normalized or fallback
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def project_root() -> Path:
|
| 104 |
+
return Path(__file__).resolve().parents[2]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def default_character_package(character_id: str, display_name: str) -> dict[str, Any]:
|
| 108 |
+
return {
|
| 109 |
+
"id": character_id,
|
| 110 |
+
"name": display_name,
|
| 111 |
+
"display_name": display_name,
|
| 112 |
+
"summary": "自动化角色生成风险验证用原创角色草案。",
|
| 113 |
+
"description": f"{display_name} 是用于验证多表情虚拟角色生成流水线的原创角色。",
|
| 114 |
+
"personality": "冷静、温柔、有清晰边界。",
|
| 115 |
+
"scenario": "用户正在通过虚拟角色实验台与角色进行对话和视觉资产测试。",
|
| 116 |
+
"first_mes": "我在。现在可以开始验证角色生成流程。",
|
| 117 |
+
"alternate_greetings": ["测试频道已接入。", "角色资产验证准备完成。"],
|
| 118 |
+
"mes_example": "",
|
| 119 |
+
"creator_notes": "由自动化角色生成 spike 创建;用于技术验证,不代表最终角色设定。",
|
| 120 |
+
"tags": ["生成测试", "原创角色", "技术验证"],
|
| 121 |
+
"profile": {
|
| 122 |
+
"identity": "自动化角色生成风险验证用原创虚拟角色",
|
| 123 |
+
"core_traits": ["冷静", "温柔", "边界清晰"],
|
| 124 |
+
"relationship_to_user": "把用户当成共同验证系统的协作者",
|
| 125 |
+
"boundaries": ["不声称自己是商业 IP 角色", "不复述商业 IP 官方设定"],
|
| 126 |
+
},
|
| 127 |
+
"dialogue_style": {
|
| 128 |
+
"tone": "自然、简短、清晰",
|
| 129 |
+
"sentence_shape": "中短句",
|
| 130 |
+
"catchphrases": ["我在。"],
|
| 131 |
+
},
|
| 132 |
+
"skills": ["daily_chat", "style_guard"],
|
| 133 |
+
"voice": {"voice_id": "default", "pace": "normal", "energy": 0.5},
|
| 134 |
+
"visual": {
|
| 135 |
+
"accent": "#67e8f9",
|
| 136 |
+
"background": "#111827",
|
| 137 |
+
"background_image": f"{character_id}_spike_background",
|
| 138 |
+
"avatar": character_id,
|
| 139 |
+
},
|
| 140 |
+
"metadata": {"source": "character_generation_spike"},
|
| 141 |
+
}
|
| 142 |
+
|
src/character_spike/tavern_import.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from src.character_spike.schema import default_character_package, slugify_identifier
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def load_tavern_json(path: str | Path) -> dict[str, Any]:
|
| 11 |
+
raw = Path(path).read_text(encoding="utf-8-sig")
|
| 12 |
+
parsed = json.loads(raw)
|
| 13 |
+
if not isinstance(parsed, dict):
|
| 14 |
+
raise ValueError("Tavern card JSON must be an object")
|
| 15 |
+
return parsed
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def convert_tavern_card(card: dict[str, Any], forced_id: str | None = None) -> dict[str, Any]:
|
| 19 |
+
data = card.get("data") if isinstance(card.get("data"), dict) else card
|
| 20 |
+
name = str(data.get("name") or data.get("char_name") or data.get("display_name") or "Imported Character").strip()
|
| 21 |
+
character_id = slugify_identifier(forced_id or name, fallback="imported_character")
|
| 22 |
+
package = default_character_package(character_id, name)
|
| 23 |
+
|
| 24 |
+
description = _text(data.get("description") or data.get("desc"))
|
| 25 |
+
personality = _text(data.get("personality"))
|
| 26 |
+
scenario = _text(data.get("scenario"))
|
| 27 |
+
first_mes = _text(data.get("first_mes") or data.get("first_message"))
|
| 28 |
+
mes_example = _text(data.get("mes_example") or data.get("example_dialogue"))
|
| 29 |
+
creator_notes = _text(data.get("creator_notes") or data.get("creatorcomment"))
|
| 30 |
+
alternate_greetings = _list_of_text(data.get("alternate_greetings") or data.get("alternate_greeting"))
|
| 31 |
+
tags = _list_of_text(data.get("tags") or data.get("tag") or [])
|
| 32 |
+
|
| 33 |
+
if description:
|
| 34 |
+
package["description"] = description
|
| 35 |
+
package["summary"] = _summary_from_text(description)
|
| 36 |
+
package["profile"]["identity"] = _first_nonempty_line(description)
|
| 37 |
+
if personality:
|
| 38 |
+
package["personality"] = personality
|
| 39 |
+
package["profile"]["core_traits"] = _traits_from_personality(personality)
|
| 40 |
+
if scenario:
|
| 41 |
+
package["scenario"] = scenario
|
| 42 |
+
package["profile"]["relationship_to_user"] = _first_nonempty_line(scenario)
|
| 43 |
+
if first_mes:
|
| 44 |
+
package["first_mes"] = first_mes
|
| 45 |
+
if mes_example:
|
| 46 |
+
package["mes_example"] = mes_example
|
| 47 |
+
if creator_notes:
|
| 48 |
+
package["creator_notes"] = creator_notes
|
| 49 |
+
if alternate_greetings:
|
| 50 |
+
package["alternate_greetings"] = alternate_greetings
|
| 51 |
+
if tags:
|
| 52 |
+
package["tags"] = tags
|
| 53 |
+
|
| 54 |
+
character_book = _extract_character_book(card, data)
|
| 55 |
+
package["metadata"] = {
|
| 56 |
+
"source": "tavern_json",
|
| 57 |
+
"spec": card.get("spec"),
|
| 58 |
+
"spec_version": card.get("spec_version") or data.get("spec_version"),
|
| 59 |
+
"creator": data.get("creator"),
|
| 60 |
+
"character_version": data.get("character_version"),
|
| 61 |
+
"raw_extensions": data.get("extensions") if isinstance(data.get("extensions"), dict) else {},
|
| 62 |
+
"character_book": character_book,
|
| 63 |
+
}
|
| 64 |
+
package["visual"]["avatar"] = character_id
|
| 65 |
+
package["visual"]["background_image"] = f"{character_id}_spike_background"
|
| 66 |
+
return package
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def write_character_draft(package: dict[str, Any], output_dir: str | Path) -> Path:
|
| 70 |
+
out_dir = Path(output_dir)
|
| 71 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 72 |
+
path = out_dir / f"{package['id']}.json"
|
| 73 |
+
path.write_text(json.dumps(package, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
| 74 |
+
return path
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _text(value: Any) -> str:
|
| 78 |
+
return str(value or "").strip()
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _list_of_text(value: Any) -> list[str]:
|
| 82 |
+
if value is None:
|
| 83 |
+
return []
|
| 84 |
+
if isinstance(value, str):
|
| 85 |
+
return [item.strip() for item in value.splitlines() if item.strip()]
|
| 86 |
+
if isinstance(value, list):
|
| 87 |
+
return [str(item).strip() for item in value if str(item).strip()]
|
| 88 |
+
return []
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _summary_from_text(text: str, limit: int = 120) -> str:
|
| 92 |
+
compact = " ".join(text.split())
|
| 93 |
+
if len(compact) <= limit:
|
| 94 |
+
return compact
|
| 95 |
+
return compact[: limit - 1].rstrip() + "…"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _first_nonempty_line(text: str) -> str:
|
| 99 |
+
for line in text.splitlines():
|
| 100 |
+
stripped = line.strip()
|
| 101 |
+
if stripped:
|
| 102 |
+
return stripped
|
| 103 |
+
return text.strip()
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _traits_from_personality(personality: str) -> list[str]:
|
| 107 |
+
separators = ["、", ",", ",", ";", ";", "\n"]
|
| 108 |
+
values = [personality]
|
| 109 |
+
for separator in separators:
|
| 110 |
+
next_values: list[str] = []
|
| 111 |
+
for value in values:
|
| 112 |
+
next_values.extend(value.split(separator))
|
| 113 |
+
values = next_values
|
| 114 |
+
traits = [item.strip() for item in values if item.strip()]
|
| 115 |
+
return traits[:8] or ["自然", "清晰"]
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _extract_character_book(card: dict[str, Any], data: dict[str, Any]) -> dict[str, Any] | None:
|
| 119 |
+
for source in (data, card):
|
| 120 |
+
for key in ("character_book", "world_info", "lorebook"):
|
| 121 |
+
value = source.get(key)
|
| 122 |
+
if isinstance(value, dict):
|
| 123 |
+
return value
|
| 124 |
+
extensions = data.get("extensions")
|
| 125 |
+
if isinstance(extensions, dict):
|
| 126 |
+
for key in ("character_book", "world_info", "lorebook"):
|
| 127 |
+
value = extensions.get(key)
|
| 128 |
+
if isinstance(value, dict):
|
| 129 |
+
return value
|
| 130 |
+
return None
|
| 131 |
+
|
tests/test_character_spike.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import unittest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from scripts.run_character_generation_spike import _evaluate_gate
|
| 8 |
+
from src.character_spike.assets import create_mock_asset_package
|
| 9 |
+
from src.character_spike.schema import EXPRESSIONS, MODEL_CANDIDATES, candidate_by_id
|
| 10 |
+
from src.character_spike.tavern_import import convert_tavern_card, load_tavern_json, write_character_draft
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 14 |
+
TMP_ROOT = PROJECT_ROOT / ".tmp" / "tests"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CharacterSpikeTests(unittest.TestCase):
|
| 18 |
+
def test_candidate_registry_tracks_required_models(self) -> None:
|
| 19 |
+
ids = {candidate.id for candidate in MODEL_CANDIDATES}
|
| 20 |
+
self.assertIn("flux_schnell", ids)
|
| 21 |
+
self.assertIn("qwen_image", ids)
|
| 22 |
+
self.assertIn("qwen_image_edit", ids)
|
| 23 |
+
self.assertIn("qwen_controlnet_union", ids)
|
| 24 |
+
self.assertIn("instantid_sdxl", ids)
|
| 25 |
+
self.assertFalse(candidate_by_id("instantid_sdxl").implemented)
|
| 26 |
+
|
| 27 |
+
def test_tavern_json_import_maps_fields_to_draft_package(self) -> None:
|
| 28 |
+
card = {
|
| 29 |
+
"spec": "chara_card_v2",
|
| 30 |
+
"spec_version": "2.0",
|
| 31 |
+
"data": {
|
| 32 |
+
"name": "测试角色",
|
| 33 |
+
"description": "一名用于导入验证的角色。\n第二行会被保留。",
|
| 34 |
+
"personality": "冷静、敏锐,温柔",
|
| 35 |
+
"scenario": "用户正在测试角色卡导入。",
|
| 36 |
+
"first_mes": "你好,我已经导入完成。",
|
| 37 |
+
"alternate_greetings": ["备用开场 A", "备用开场 B"],
|
| 38 |
+
"creator_notes": "只用于测试。",
|
| 39 |
+
"tags": ["测试", "导入"],
|
| 40 |
+
"character_book": {"entries": [{"keys": ["地点"], "content": "测试空间"}]},
|
| 41 |
+
},
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
package = convert_tavern_card(card)
|
| 45 |
+
|
| 46 |
+
self.assertEqual(package["display_name"], "测试角色")
|
| 47 |
+
self.assertEqual(package["first_mes"], "你好,我已经导入完成。")
|
| 48 |
+
self.assertEqual(package["alternate_greetings"], ["备用开场 A", "备用开场 B"])
|
| 49 |
+
self.assertEqual(package["profile"]["core_traits"][:3], ["冷静", "敏锐", "温柔"])
|
| 50 |
+
self.assertEqual(package["metadata"]["spec"], "chara_card_v2")
|
| 51 |
+
self.assertEqual(package["metadata"]["character_book"]["entries"][0]["content"], "测试空间")
|
| 52 |
+
|
| 53 |
+
def test_load_and_write_tavern_draft(self) -> None:
|
| 54 |
+
root = TMP_ROOT / "tavern_draft"
|
| 55 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 56 |
+
source = root / "card.json"
|
| 57 |
+
source.write_text(json.dumps({"name": "Plain Card", "first_mes": "Hi"}), encoding="utf-8")
|
| 58 |
+
|
| 59 |
+
package = convert_tavern_card(load_tavern_json(source), forced_id="plain_card")
|
| 60 |
+
path = write_character_draft(package, root / "characters")
|
| 61 |
+
|
| 62 |
+
self.assertTrue(path.exists())
|
| 63 |
+
parsed = json.loads(path.read_text(encoding="utf-8"))
|
| 64 |
+
self.assertEqual(parsed["id"], "plain_card")
|
| 65 |
+
self.assertEqual(parsed["first_mes"], "Hi")
|
| 66 |
+
|
| 67 |
+
def test_mock_asset_package_creates_manifest_grid_and_assets(self) -> None:
|
| 68 |
+
output_root = TMP_ROOT / "mock_assets"
|
| 69 |
+
output_root.mkdir(parents=True, exist_ok=True)
|
| 70 |
+
manifest = create_mock_asset_package(
|
| 71 |
+
character_id="spike_test",
|
| 72 |
+
display_name="星核",
|
| 73 |
+
output_root=output_root,
|
| 74 |
+
seed=7,
|
| 75 |
+
)
|
| 76 |
+
run_dir = Path(manifest["paths"]["run_dir"])
|
| 77 |
+
|
| 78 |
+
self.assertEqual(len(manifest["assets"]), len(EXPRESSIONS))
|
| 79 |
+
self.assertTrue((run_dir / "generated" / "manifest.json").exists())
|
| 80 |
+
self.assertTrue((run_dir / "generated" / "report.md").exists())
|
| 81 |
+
self.assertTrue((run_dir / "generated" / "asset_grid.png").exists())
|
| 82 |
+
for expression in EXPRESSIONS:
|
| 83 |
+
self.assertTrue((run_dir / "assets" / "characters" / "spike_test" / f"{expression}.png").exists())
|
| 84 |
+
|
| 85 |
+
def test_gate_requires_timing_and_usable_assets(self) -> None:
|
| 86 |
+
manifest = {
|
| 87 |
+
"assets": [{"usable": True} for _ in range(6)],
|
| 88 |
+
"model_results": [
|
| 89 |
+
{
|
| 90 |
+
"benchmark_case": "warm_four",
|
| 91 |
+
"status": "ok",
|
| 92 |
+
"loaded_before": True,
|
| 93 |
+
"duration_seconds": 55.0,
|
| 94 |
+
},
|
| 95 |
+
{
|
| 96 |
+
"benchmark_case": "eight_expression_total",
|
| 97 |
+
"status": "ok",
|
| 98 |
+
"duration_seconds": 170.0,
|
| 99 |
+
},
|
| 100 |
+
],
|
| 101 |
+
}
|
| 102 |
+
gate = _evaluate_gate(manifest)
|
| 103 |
+
self.assertTrue(gate["ready_for_character_workshop"])
|
| 104 |
+
|
| 105 |
+
manifest["model_results"][1]["duration_seconds"] = 240.0
|
| 106 |
+
gate = _evaluate_gate(manifest)
|
| 107 |
+
self.assertFalse(gate["ready_for_character_workshop"])
|
| 108 |
+
self.assertIn("导入角色卡", gate["fallback_recommendation"])
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
if __name__ == "__main__":
|
| 112 |
+
unittest.main()
|
tests/test_character_workshop.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import io
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import unittest
|
| 7 |
+
import uuid
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from PIL import Image, ImageDraw
|
| 11 |
+
|
| 12 |
+
import src.character_workshop as workshop
|
| 13 |
+
from src.character_registry import load_user_character_packages
|
| 14 |
+
from src.model_status import ModelStatus, statuses_markdown
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 18 |
+
TMP_ROOT = PROJECT_ROOT / ".tmp" / "workshop_tests"
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class CharacterWorkshopTests(unittest.TestCase):
|
| 22 |
+
def setUp(self) -> None:
|
| 23 |
+
self.test_root = TMP_ROOT / f"{self._testMethodName}_{uuid.uuid4().hex}"
|
| 24 |
+
self.test_root.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
self.old_roots = (
|
| 26 |
+
workshop.WORKSHOP_ROOT,
|
| 27 |
+
workshop.INSTALLED_CHARACTER_ROOT,
|
| 28 |
+
workshop.INSTALLED_BACKGROUND_ROOT,
|
| 29 |
+
workshop.INSTALLED_CHARACTER_JSON_ROOT,
|
| 30 |
+
)
|
| 31 |
+
self.old_data_root = os.environ.get("VC_DATA_ROOT")
|
| 32 |
+
workshop.WORKSHOP_ROOT = self.test_root / "generated"
|
| 33 |
+
workshop.INSTALLED_CHARACTER_ROOT = self.test_root / "installed" / "assets" / "characters"
|
| 34 |
+
workshop.INSTALLED_BACKGROUND_ROOT = self.test_root / "installed" / "assets" / "backgrounds"
|
| 35 |
+
workshop.INSTALLED_CHARACTER_JSON_ROOT = self.test_root / "installed" / "characters"
|
| 36 |
+
os.environ["VC_DATA_ROOT"] = str(self.test_root / "data")
|
| 37 |
+
workshop.set_remote_probe_for_tests(self._fake_probe)
|
| 38 |
+
self.probe_calls = []
|
| 39 |
+
|
| 40 |
+
def tearDown(self) -> None:
|
| 41 |
+
(
|
| 42 |
+
workshop.WORKSHOP_ROOT,
|
| 43 |
+
workshop.INSTALLED_CHARACTER_ROOT,
|
| 44 |
+
workshop.INSTALLED_BACKGROUND_ROOT,
|
| 45 |
+
workshop.INSTALLED_CHARACTER_JSON_ROOT,
|
| 46 |
+
) = self.old_roots
|
| 47 |
+
if self.old_data_root is None:
|
| 48 |
+
os.environ.pop("VC_DATA_ROOT", None)
|
| 49 |
+
else:
|
| 50 |
+
os.environ["VC_DATA_ROOT"] = self.old_data_root
|
| 51 |
+
workshop.set_remote_probe_for_tests(None)
|
| 52 |
+
|
| 53 |
+
def test_form_to_installable_character_package(self) -> None:
|
| 54 |
+
draft = workshop.create_draft_from_form(
|
| 55 |
+
display_name="工坊角色",
|
| 56 |
+
description="银白短发的原创角色。",
|
| 57 |
+
personality="冷静、温柔",
|
| 58 |
+
scenario="通讯端测试。",
|
| 59 |
+
first_mes="我在。",
|
| 60 |
+
tags="测试,工坊",
|
| 61 |
+
)
|
| 62 |
+
state = workshop.create_initial_state(draft)
|
| 63 |
+
state = workshop.generate_main_candidates(state)
|
| 64 |
+
self.assertEqual(len(state["main_candidates"]), 4)
|
| 65 |
+
|
| 66 |
+
state = workshop.select_main_candidate(state, 2)
|
| 67 |
+
self.assertEqual(state["selected_candidate_index"], 2)
|
| 68 |
+
|
| 69 |
+
state = workshop.generate_expression_pack(state)
|
| 70 |
+
self.assertEqual(set(state["expression_assets"]), set(workshop.EXPRESSIONS))
|
| 71 |
+
|
| 72 |
+
state = workshop.generate_background(state)
|
| 73 |
+
self.assertTrue(Path(state["background_asset"]).exists())
|
| 74 |
+
|
| 75 |
+
state = workshop.matte_and_package_assets(state, mode="fallback")
|
| 76 |
+
package_dir = Path(state["package_dir"])
|
| 77 |
+
self.assertTrue((package_dir / "generated" / "asset_grid.png").exists())
|
| 78 |
+
self.assertTrue((package_dir / "generated" / "stage_smoke.html").exists())
|
| 79 |
+
|
| 80 |
+
state = workshop.install_character_package(state)
|
| 81 |
+
character_id = state["installed_character_id"]
|
| 82 |
+
self.assertTrue((workshop.INSTALLED_CHARACTER_JSON_ROOT / f"{character_id}.json").exists())
|
| 83 |
+
self.assertTrue((workshop.INSTALLED_BACKGROUND_ROOT / f"{character_id}_background.png").exists())
|
| 84 |
+
for expression in workshop.EXPRESSIONS:
|
| 85 |
+
self.assertTrue((workshop.INSTALLED_CHARACTER_ROOT / character_id / f"{expression}.png").exists())
|
| 86 |
+
|
| 87 |
+
def test_user_character_loader_reads_installed_json(self) -> None:
|
| 88 |
+
root = TMP_ROOT / "registry"
|
| 89 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 90 |
+
path = root / "loaded_character.json"
|
| 91 |
+
path.write_text(json.dumps({"id": "loaded_character", "display_name": "已加载角色"}), encoding="utf-8")
|
| 92 |
+
|
| 93 |
+
packages = load_user_character_packages(root)
|
| 94 |
+
|
| 95 |
+
self.assertEqual(packages["loaded_character"]["display_name"], "已加载角色")
|
| 96 |
+
|
| 97 |
+
def test_image_generation_status_markdown(self) -> None:
|
| 98 |
+
html = statuses_markdown(
|
| 99 |
+
[
|
| 100 |
+
ModelStatus(
|
| 101 |
+
"image_generation",
|
| 102 |
+
"sleeping",
|
| 103 |
+
"已休眠",
|
| 104 |
+
url="modal_apps/modal_character_spike.py",
|
| 105 |
+
message="Modal 图像生成服务可能已休眠或正在冷启动,请等待容器启动和模型载入后重试。",
|
| 106 |
+
)
|
| 107 |
+
]
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
self.assertIn("Image Generation", html)
|
| 111 |
+
self.assertIn("等待容器启动", html)
|
| 112 |
+
|
| 113 |
+
def test_logged_in_runs_are_user_scoped_and_loadable(self) -> None:
|
| 114 |
+
alice = workshop.get_current_user(_Profile("alice", "Alice"))
|
| 115 |
+
bob = workshop.get_current_user(_Profile("bob", "Bob"))
|
| 116 |
+
|
| 117 |
+
alice_state = workshop.create_initial_state(
|
| 118 |
+
workshop.create_draft_from_form(
|
| 119 |
+
display_name="Alice 角色",
|
| 120 |
+
description="测试。",
|
| 121 |
+
personality="冷静",
|
| 122 |
+
scenario="测试。",
|
| 123 |
+
first_mes="你好。",
|
| 124 |
+
),
|
| 125 |
+
user=alice,
|
| 126 |
+
)
|
| 127 |
+
bob_state = workshop.create_initial_state(
|
| 128 |
+
workshop.create_draft_from_form(
|
| 129 |
+
display_name="Bob 角色",
|
| 130 |
+
description="测试。",
|
| 131 |
+
personality="冷静",
|
| 132 |
+
scenario="测试。",
|
| 133 |
+
first_mes="你好。",
|
| 134 |
+
),
|
| 135 |
+
user=bob,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
alice_runs = workshop.list_user_workshop_runs(alice)
|
| 139 |
+
bob_runs = workshop.list_user_workshop_runs(bob)
|
| 140 |
+
|
| 141 |
+
self.assertEqual(len(alice_runs), 1)
|
| 142 |
+
self.assertEqual(len(bob_runs), 1)
|
| 143 |
+
self.assertIn("Alice 角色", alice_runs[0][0])
|
| 144 |
+
self.assertIn("Bob 角色", bob_runs[0][0])
|
| 145 |
+
self.assertNotEqual(alice_state["run_dir"], bob_state["run_dir"])
|
| 146 |
+
loaded = workshop.load_workshop_run(alice_runs[0][1], user=alice)
|
| 147 |
+
self.assertEqual(loaded["character_id"], alice_state["character_id"])
|
| 148 |
+
with self.assertRaises(ValueError):
|
| 149 |
+
workshop.load_workshop_run(alice_runs[0][1], user=bob)
|
| 150 |
+
|
| 151 |
+
def test_resume_main_candidates_from_manifest(self) -> None:
|
| 152 |
+
user = workshop.get_current_user(_Profile("alice", "Alice"))
|
| 153 |
+
state = workshop.create_initial_state(
|
| 154 |
+
workshop.create_draft_from_form(
|
| 155 |
+
display_name="恢复角色",
|
| 156 |
+
description="测试。",
|
| 157 |
+
personality="冷静",
|
| 158 |
+
scenario="测试。",
|
| 159 |
+
first_mes="你好。",
|
| 160 |
+
),
|
| 161 |
+
user=user,
|
| 162 |
+
)
|
| 163 |
+
state = workshop.generate_main_candidates(state)
|
| 164 |
+
|
| 165 |
+
loaded = workshop.load_workshop_run(state["run_dir"], user=user)
|
| 166 |
+
|
| 167 |
+
self.assertEqual(len(loaded["main_candidates"]), 4)
|
| 168 |
+
self.assertEqual(loaded["selected_candidate_index"], 0)
|
| 169 |
+
|
| 170 |
+
def test_partial_expression_pack_only_generates_missing_slots(self) -> None:
|
| 171 |
+
user = workshop.get_current_user(_Profile("alice", "Alice"))
|
| 172 |
+
state = workshop.create_initial_state(
|
| 173 |
+
workshop.create_draft_from_form(
|
| 174 |
+
display_name="续跑角色",
|
| 175 |
+
description="测试。",
|
| 176 |
+
personality="冷静",
|
| 177 |
+
scenario="测试。",
|
| 178 |
+
first_mes="你好。",
|
| 179 |
+
),
|
| 180 |
+
user=user,
|
| 181 |
+
)
|
| 182 |
+
run_dir = Path(state["run_dir"])
|
| 183 |
+
idle_dir = run_dir / "expressions_raw" / "idle"
|
| 184 |
+
idle_dir.mkdir(parents=True, exist_ok=True)
|
| 185 |
+
idle_path = idle_dir / "00.png"
|
| 186 |
+
idle_path.write_bytes(_png_bytes(0))
|
| 187 |
+
manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8"))
|
| 188 |
+
manifest["expression_assets_raw"] = {"idle": str(idle_path.relative_to(run_dir))}
|
| 189 |
+
(run_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
|
| 190 |
+
|
| 191 |
+
self.probe_calls.clear()
|
| 192 |
+
state = workshop.load_workshop_run(run_dir, user=user)
|
| 193 |
+
state = workshop.generate_expression_pack(state)
|
| 194 |
+
|
| 195 |
+
self.assertEqual(set(state["expression_assets"]), set(workshop.EXPRESSIONS))
|
| 196 |
+
self.assertEqual(len(self.probe_calls), len(workshop.EXPRESSIONS) - 1)
|
| 197 |
+
|
| 198 |
+
def test_stats_event_summary(self) -> None:
|
| 199 |
+
user = workshop.get_current_user(_Profile("alice", "Alice"))
|
| 200 |
+
|
| 201 |
+
workshop.record_workshop_event(user, "generate_main_candidates", {"stage": "main_candidates", "success": True, "duration_seconds": 1.2, "character_id": "x"})
|
| 202 |
+
workshop.record_workshop_event(user, "generate_expression_pack", {"stage": "assets_ready", "success": False, "failure_reason": "boom", "character_id": "x"})
|
| 203 |
+
summary = workshop.summarize_workshop_stats()
|
| 204 |
+
|
| 205 |
+
self.assertEqual(summary["events"], 2)
|
| 206 |
+
self.assertEqual(summary["users"], 1)
|
| 207 |
+
self.assertEqual(summary["failures_by_stage"]["assets_ready"], 1)
|
| 208 |
+
|
| 209 |
+
def _fake_probe(self, **kwargs):
|
| 210 |
+
self.probe_calls.append(dict(kwargs))
|
| 211 |
+
batch_size = int(kwargs.get("batch_size", 1))
|
| 212 |
+
images = [_png_bytes(index) for index in range(batch_size)]
|
| 213 |
+
return {
|
| 214 |
+
"candidate_id": kwargs.get("candidate_id", "qwen_image"),
|
| 215 |
+
"status": "ok",
|
| 216 |
+
"image_count": batch_size,
|
| 217 |
+
"duration_seconds": 0.01,
|
| 218 |
+
"seed": kwargs.get("seed"),
|
| 219 |
+
"images": images,
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _png_bytes(index: int) -> bytes:
|
| 224 |
+
image = Image.new("RGB", (256, 384), (238, 246, 248))
|
| 225 |
+
draw = ImageDraw.Draw(image)
|
| 226 |
+
accent = [(45, 212, 191), (96, 165, 250), (244, 114, 182), (250, 204, 21)][index % 4]
|
| 227 |
+
draw.ellipse((76, 36, 180, 150), fill=(245, 222, 214))
|
| 228 |
+
draw.rectangle((84, 150, 172, 330), fill=(35, 45, 60))
|
| 229 |
+
draw.rectangle((112, 166, 144, 310), fill=accent)
|
| 230 |
+
buffer = io.BytesIO()
|
| 231 |
+
image.save(buffer, format="PNG")
|
| 232 |
+
return buffer.getvalue()
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
class _Profile:
|
| 236 |
+
def __init__(self, username: str, name: str):
|
| 237 |
+
self.username = username
|
| 238 |
+
self.name = name
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
if __name__ == "__main__":
|
| 242 |
+
unittest.main()
|
tests/test_dialogue_engine.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from unittest.mock import patch
|
| 7 |
+
|
| 8 |
+
from src import dialogue_engine
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DialogueEngineMultimodalTests(unittest.TestCase):
|
| 12 |
+
def test_build_user_content_adds_image_url(self):
|
| 13 |
+
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as handle:
|
| 14 |
+
handle.write(b"\x89PNG\r\n\x1a\n")
|
| 15 |
+
image_path = Path(handle.name)
|
| 16 |
+
|
| 17 |
+
try:
|
| 18 |
+
content = dialogue_engine._build_user_content(
|
| 19 |
+
"看一下这张图",
|
| 20 |
+
{"images": [{"path": str(image_path)}]},
|
| 21 |
+
)
|
| 22 |
+
finally:
|
| 23 |
+
image_path.unlink(missing_ok=True)
|
| 24 |
+
|
| 25 |
+
self.assertIsInstance(content, list)
|
| 26 |
+
self.assertEqual(content[0], {"type": "text", "text": "看一下这张图"})
|
| 27 |
+
self.assertEqual(content[1]["type"], "image_url")
|
| 28 |
+
self.assertTrue(content[1]["image_url"]["url"].startswith("data:image/png;base64,"))
|
| 29 |
+
|
| 30 |
+
def test_build_user_content_ignores_audio_attachment(self):
|
| 31 |
+
content = dialogue_engine._build_user_content(
|
| 32 |
+
"继续文字对话",
|
| 33 |
+
{"images": [], "audio": [{"path": "ignored.wav"}]},
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
self.assertEqual(content, [{"type": "text", "text": "继续文字对话"}])
|
| 37 |
+
|
| 38 |
+
def test_vllm_failure_returns_regular_error_without_audio_fallback(self):
|
| 39 |
+
class FailingClient:
|
| 40 |
+
def __init__(self, *args, **kwargs):
|
| 41 |
+
pass
|
| 42 |
+
|
| 43 |
+
def __enter__(self):
|
| 44 |
+
return self
|
| 45 |
+
|
| 46 |
+
def __exit__(self, *args):
|
| 47 |
+
return False
|
| 48 |
+
|
| 49 |
+
def stream(self, *args, **kwargs):
|
| 50 |
+
raise RuntimeError("400 Bad Request")
|
| 51 |
+
|
| 52 |
+
with patch.dict("os.environ", {"VC_VLLM_RETRIES": "1"}, clear=False):
|
| 53 |
+
with patch("httpx.Client", FailingClient):
|
| 54 |
+
events = list(
|
| 55 |
+
dialogue_engine._stream_vllm_reply(
|
| 56 |
+
"https://example.test",
|
| 57 |
+
"你好",
|
| 58 |
+
[],
|
| 59 |
+
{"character": {"voice": {}}},
|
| 60 |
+
{"images": []},
|
| 61 |
+
{"enabled": False},
|
| 62 |
+
)
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
errors = [event["message"] for event in events if event.get("type") == "error"]
|
| 66 |
+
self.assertTrue(any("Modal vLLM 调用失败" in message for message in errors))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
if __name__ == "__main__":
|
| 70 |
+
unittest.main()
|
tests/test_model_status.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import unittest
|
| 5 |
+
|
| 6 |
+
from src.model_status import ModelStatus, statuses_with_llm_status, warm_llm_model
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ModelStatusTests(unittest.TestCase):
|
| 10 |
+
def test_warm_llm_model_skips_remote_when_mock_enabled(self) -> None:
|
| 11 |
+
old_value = os.environ.get("VC_USE_MOCK")
|
| 12 |
+
os.environ["VC_USE_MOCK"] = "1"
|
| 13 |
+
try:
|
| 14 |
+
status = warm_llm_model(timeout_s=0.01)
|
| 15 |
+
finally:
|
| 16 |
+
if old_value is None:
|
| 17 |
+
os.environ.pop("VC_USE_MOCK", None)
|
| 18 |
+
else:
|
| 19 |
+
os.environ["VC_USE_MOCK"] = old_value
|
| 20 |
+
|
| 21 |
+
self.assertEqual(status.kind, "llm")
|
| 22 |
+
self.assertEqual(status.state, "mock")
|
| 23 |
+
self.assertIn("mock", status.message.lower())
|
| 24 |
+
|
| 25 |
+
def test_statuses_with_llm_status_replaces_initial_llm_row(self) -> None:
|
| 26 |
+
llm_status = ModelStatus("llm", "loading", "载入中", message="正在启动主模型")
|
| 27 |
+
|
| 28 |
+
statuses = statuses_with_llm_status(llm_status)
|
| 29 |
+
|
| 30 |
+
self.assertGreaterEqual(len(statuses), 1)
|
| 31 |
+
self.assertEqual(statuses[0], llm_status)
|
| 32 |
+
self.assertEqual(statuses[0].message, "正在启动主模型")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
if __name__ == "__main__":
|
| 36 |
+
unittest.main()
|