ShadowInk commited on
Commit
005e075
·
verified ·
1 Parent(s): fcda31d

Deploy Virtual Characters for Build Small Hackathon

Browse files
.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: green
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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

  • SHA256: 7d3da7bb774a8ea864bc738ab93061cbdffa555b497d1eb10edc6f39effe90da
  • Pointer size: 131 Bytes
  • Size of remote file: 786 kB
assets/characters/star/focus_preview_check.png ADDED

Git LFS Details

  • SHA256: cfcff3861238554fe954e5acfdb2d67b35c91a4b9293c9873808285cf401e815
  • Pointer size: 131 Bytes
  • Size of remote file: 795 kB
assets/characters/star/focus_preview_check2.png ADDED

Git LFS Details

  • SHA256: d300032bb4ef31a1caaaa626620d0009792efda7f1e7221de586dada3ce2e3a4
  • Pointer size: 131 Bytes
  • Size of remote file: 793 kB
assets/characters/star/happy.png ADDED

Git LFS Details

  • SHA256: 5f4a2510321de12c126dad2d484a8ab8129793bfe76a0623070850927baeff78
  • Pointer size: 131 Bytes
  • Size of remote file: 736 kB
assets/characters/star/idle.png ADDED

Git LFS Details

  • SHA256: a586ed069b9331d9968aa893f5570691364a440cfd7c4c4da3bb06541dbcf963
  • Pointer size: 131 Bytes
  • Size of remote file: 727 kB
assets/characters/star/listening.png ADDED

Git LFS Details

  • SHA256: e7595d9a8d3323841f27e70d730b2683528a4aa40d060f003399593da10d6610
  • Pointer size: 131 Bytes
  • Size of remote file: 730 kB
assets/characters/star/smile.png ADDED

Git LFS Details

  • SHA256: 77c08e75b96bb962a4e303689d9151bcdb2f80df549504293dc6d0d1c27e2c38
  • Pointer size: 131 Bytes
  • Size of remote file: 695 kB
assets/characters/star/talk.png ADDED

Git LFS Details

  • SHA256: 79457b214f21d0a9801432797d989ef33829e9662df868f2c212d0ddfc4be335
  • Pointer size: 131 Bytes
  • Size of remote file: 665 kB
assets/characters/star/thinking.png ADDED

Git LFS Details

  • SHA256: 601cb92b8183d0a384e902c03dfd391d6f6fe39bd026d6286d3512e64fa37dc8
  • Pointer size: 131 Bytes
  • Size of remote file: 681 kB
assets/characters/star/worried.png ADDED

Git LFS Details

  • SHA256: cb79c4fe2f0259ecc39d044cd83722b9fc83411967c083526a288a8bc2d5e540
  • Pointer size: 131 Bytes
  • Size of remote file: 711 kB
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()