Skip to content

Commit d5ea4da

Browse files
feat: true streaming, lipsync, character presets, runtime config
- Replace fake streaming with real token-by-token streaming using ===META=== marker to separate reply text from emotion/action JSON - Add lipsync mouth animation driven by time-based viseme loop in TTSService; CyberAvatar renders mouth mesh that syncs with TTS - Ship 4 character presets (lively-assistant, serious-advisor, cute-companion, pro-service) with backend-side prompt mapping; frontend sends only characterId to avoid prompt injection - Add runtime API endpoint override in settings drawer config tab, persisted in localStorage and reapplied on startup via ServicesProvider, taking precedence over env config - Localize new settings UI sections (zh-CN + en) Tests: +18 frontend, +3 backend (274 frontend / 40 backend total) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 49a0ae6 commit d5ea4da

22 files changed

Lines changed: 1019 additions & 118 deletions

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
## [Unreleased]
1111

12+
### 🎭 Streaming & Lipsync
13+
14+
- **True streaming dialogue** — Replaced fake streaming in the Python backend with real token-by-token streaming. The LLM now outputs plain reply text first, followed by a `===META===` marker line carrying the emotion/action JSON, so users see tokens appear as the LLM generates them instead of after the full response completes.
15+
- **Lipsync mouth animation** — Added a `mouthOpen` state channel driven by a time-based viseme loop in `TTSService` (≈16Hz sinusoidal + noise simulation). The procedural `CyberAvatar` now renders a mouth mesh that opens and closes in sync with TTS playback, and `onSpeakEnd` cleanly resets the mouth to closed.
16+
17+
### 🧑‍🎤 Character Presets
18+
19+
- **Built-in character presets** — Shipped 4 character personas (`lively-assistant`, `serious-advisor`, `cute-companion`, `pro-service`) with backend-side system prompt mapping. The frontend only sends a `characterId`; the backend controls the prompt to avoid injection. A preset selector is now available in the settings drawer's basic tab.
20+
- **`characterId` in dialogue metadata**`buildDialogueRequestMeta` now carries an optional `characterId` field, and `useChatStream` forwards the active preset from `digitalHumanStore` on every streaming turn.
21+
22+
### ⚙️ Runtime Configuration
23+
24+
- **Runtime API endpoint override** — Added a `config` tab in the settings drawer for overriding the dialogue backend base URL and fallback endpoints at runtime. The override is persisted in `localStorage` and reapplied on app startup via `ServicesProvider`, taking precedence over `VITE_API_BASE_URL` env config. A reset button restores the env defaults.
25+
26+
### 🧪 Tests
27+
28+
- Added `lipsync.test.ts` (7 tests) covering `mouthOpen` clamping, TTS callback wiring, and the viseme loop start/stop lifecycle.
29+
- Added `characterPresets.test.ts` (6 tests) covering preset uniqueness, default fallback, and validation.
30+
- Added `runtimeApiConfig.test.ts` (5 tests) covering `localStorage` persistence and `applyRuntimeApiEndpoints` / `resetRuntimeApiEndpoints` routing.
31+
- Extended `dialogueRequestMeta.test.ts` with `characterId` inclusion/omission cases.
32+
- Extended backend `test_dialogue_service.py` with a `CharacterPresetTest` class (3 tests) verifying character prompt selection, unknown-id fallback, and default behavior.
33+
1234
### ✨ Runtime Capabilities
1335

1436
- Added structured dialogue request metadata so language preference, speech configuration, and recent vision context travel together with each chat turn

examples/backend-python/app/services/dialogue.py

Lines changed: 139 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,44 @@
1212

1313
logger = logging.getLogger(__name__)
1414

15+
# 流式协议标记:LLM 先输出 replyText 纯文本,新起一行输出该标记 + meta JSON
16+
STREAM_META_MARKER = "===META==="
17+
_VALID_EMOTIONS = {"neutral", "happy", "surprised", "sad", "angry"}
18+
_VALID_ACTIONS = {"idle", "wave", "greet", "think", "nod", "shakeHead", "dance", "speak"}
19+
20+
# 角色预设 → system prompt 映射。前端只传 characterId,后端控制 prompt(避免注入)。
21+
CHARACTER_PROMPTS = {
22+
"lively-assistant": (
23+
"你是一个活泼、友好的虚拟数字人对话大脑,负责驱动屏幕上的数字人。"
24+
"必须使用简体中文、自然口语风格回答用户,语气偏轻松、积极。"
25+
"你需要根据用户的话尽量多地使用非 neutral 的 emotion 和非 idle 的 action,"
26+
"但在严肃、负面话题时要适当收敛,不要过度夸张。"
27+
),
28+
"serious-advisor": (
29+
"你是一位稳重、专业的虚拟顾问。必须使用简体中文,语气克制、准确、有条理。"
30+
"回答要言简意赅,避免夸张表情,emotion 多用 neutral,仅在强调重点时用 surprised,"
31+
"负面或警示话题可用 sad/angry。action 多用 nod/think,避免 dance/wave。"
32+
),
33+
"cute-companion": (
34+
"你是一个俏皮可爱的虚拟伙伴。必须使用简体中文,语气活泼、卖萌、多用语气词。"
35+
"尽量多用 happy/surprised 的 emotion 和 wave/greet/dance 的 action,"
36+
"让数字人显得灵动。但遇到严肃话题时适当收敛。"
37+
),
38+
"pro-service": (
39+
"你是一位礼貌、规范的虚拟客服。必须使用简体中文,语气专业、简洁、得体。"
40+
"回答直接了当,不卖弄。emotion 多用 neutral,招呼时用 happy,"
41+
"action 多用 greet/nod,避免 dance 等娱乐性动作。"
42+
),
43+
}
44+
45+
DEFAULT_CHARACTER_ID = "lively-assistant"
46+
47+
48+
def _get_character_prompt(character_id: Optional[str]) -> str:
49+
"""根据 characterId 选 system prompt,未识别时回退默认。"""
50+
cid = (character_id or "").strip() or DEFAULT_CHARACTER_ID
51+
return CHARACTER_PROMPTS.get(cid, CHARACTER_PROMPTS[DEFAULT_CHARACTER_ID])
52+
1553

1654
class DialogueService:
1755
def __init__(self) -> None:
@@ -98,19 +136,8 @@ async def generate_reply(
98136
self._append_turn(session_id, user_text, result["replyText"])
99137
return result
100138

101-
system_prompt = (
102-
"你是一个活泼、友好的虚拟数字人对话大脑,负责驱动屏幕上的数字人。"
103-
"必须使用简体中文、自然口语风格回答用户,语气偏轻松、积极。"
104-
"你需要根据用户的话尽量多地使用非 neutral 的 emotion 和非 idle 的 action,"
105-
"但在严肃、负面话题时要适当收敛,不要过度夸张。"
106-
"请只输出一个 JSON 对象,包含三个字段:"
107-
"replyText(字符串,给用户的自然语言回答,要友好自然),"
108-
"emotion(字符串,取值限定为: neutral, happy, surprised, sad, angry),"
109-
"action(字符串,取值限定为: idle, wave, greet, think, nod, shakeHead, dance, speak)。"
110-
"emotion 取值建议:正向场景多用 happy;惊喜时用 surprised;负面情绪时用 sad;严肃提醒时用 angry;普通回答用 neutral。"
111-
"action 取值建议:招呼告别用 greet/wave;思考用 think/nod;否定用 shakeHead;庆祝用 dance;说话用 speak;静止用 idle。"
112-
"严禁输出 JSON 以外的任何文字。"
113-
)
139+
character_id = (meta or {}).get("characterId") if isinstance(meta, dict) else None
140+
system_prompt = self._build_system_prompt(character_id)
114141

115142
history_messages: list[dict[str, str]] = []
116143
if session_id:
@@ -253,22 +280,52 @@ async def generate_reply_stream(
253280
yield json.dumps({"type": "done", **result}, ensure_ascii=False)
254281
return
255282

256-
system_prompt = self._build_system_prompt()
283+
system_prompt = self._build_streaming_system_prompt(
284+
(meta or {}).get("characterId") if isinstance(meta, dict) else None
285+
)
257286
messages = self._build_messages(session_id, user_text, meta, system_prompt)
258287

259288
try:
260-
collected_content = ""
261-
async for chunk_text in self._call_llm_stream(messages):
262-
collected_content += chunk_text
289+
collected_reply = ""
290+
meta_text = ""
291+
buffer = ""
292+
in_meta = False
263293

264-
parsed = self._parse_llm_response(collected_content, user_text)
265-
self._append_turn(session_id, user_text, parsed["replyText"])
294+
# 真流式:直接转发 LLM token,扫描 marker 分离 replyText 和 meta
295+
async for chunk_text in self._call_llm_stream(messages):
296+
if in_meta:
297+
meta_text += chunk_text
298+
continue
299+
300+
buffer += chunk_text
301+
safe_text, buffer, marker_found = self._split_stream_chunk(buffer)
302+
303+
if safe_text:
304+
collected_reply += safe_text
305+
yield json.dumps({"type": "token", "content": safe_text}, ensure_ascii=False)
306+
307+
if marker_found:
308+
in_meta = True
309+
meta_text = buffer
310+
buffer = ""
311+
312+
# 流结束:处理剩余 buffer 和 meta
313+
if not in_meta:
314+
# LLM 未输出 marker,剩余 buffer 当 replyText
315+
if buffer:
316+
collected_reply += buffer
317+
yield json.dumps({"type": "token", "content": buffer}, ensure_ascii=False)
318+
emotion, action = "neutral", "idle"
319+
else:
320+
emotion, action = self._parse_stream_meta(meta_text)
266321

267-
# 逐字流式输出 replyText(而非原始 JSON 碎片)
268-
for char in parsed["replyText"]:
269-
yield json.dumps({"type": "token", "content": char}, ensure_ascii=False)
322+
reply_text = collected_reply.strip() or f"你刚才说:{user_text}"
323+
self._append_turn(session_id, user_text, reply_text)
270324

271-
yield json.dumps({"type": "done", **parsed}, ensure_ascii=False)
325+
yield json.dumps(
326+
{"type": "done", "replyText": reply_text, "emotion": emotion, "action": action},
327+
ensure_ascii=False,
328+
)
272329

273330
except Exception as exc:
274331
logger.exception("流式 LLM 调用失败: %s", exc)
@@ -277,13 +334,11 @@ async def generate_reply_stream(
277334
yield json.dumps({"type": "error", "message": str(exc)}, ensure_ascii=False)
278335
yield json.dumps({"type": "done", **result}, ensure_ascii=False)
279336

280-
def _build_system_prompt(self) -> str:
337+
def _build_system_prompt(self, character_id: Optional[str] = None) -> str:
338+
character_intro = _get_character_prompt(character_id)
281339
return (
282-
"你是一个活泼、友好的虚拟数字人对话大脑,负责驱动屏幕上的数字人。"
283-
"必须使用简体中文、自然口语风格回答用户,语气偏轻松、积极。"
284-
"你需要根据用户的话尽量多地使用非 neutral 的 emotion 和非 idle 的 action,"
285-
"但在严肃、负面话题时要适当收敛,不要过度夸张。"
286-
"请只输出一个 JSON 对象,包含三个字段:"
340+
character_intro
341+
+ "请只输出一个 JSON 对象,包含三个字段:"
287342
"replyText(字符串,给用户的自然语言回答,要友好自然),"
288343
"emotion(字符串,取值限定为: neutral, happy, surprised, sad, angry),"
289344
"action(字符串,取值限定为: idle, wave, greet, think, nod, shakeHead, dance, speak)。"
@@ -292,6 +347,61 @@ def _build_system_prompt(self) -> str:
292347
"严禁输出 JSON 以外的任何文字。"
293348
)
294349

350+
def _build_streaming_system_prompt(self, character_id: Optional[str] = None) -> str:
351+
"""流式专用 prompt:先输出 replyText 纯文本,再新起一行输出 marker + meta JSON。"""
352+
character_intro = _get_character_prompt(character_id)
353+
return (
354+
character_intro
355+
+ "\n\n输出格式(严格遵守):\n"
356+
"1. 先直接输出给用户的自然语言回答(replyText),就是纯文本,不要任何 JSON 或标记。\n"
357+
f"2. 回答结束后,新起一行输出 {STREAM_META_MARKER},然后在同一行紧跟一个 JSON 对象:"
358+
"{\"emotion\":\"...\",\"action\":\"...\"}\n"
359+
"emotion 取值限定: neutral, happy, surprised, sad, angry\n"
360+
"action 取值限定: idle, wave, greet, think, nod, shakeHead, dance, speak\n"
361+
f"严禁在 replyText 部分出现 {STREAM_META_MARKER} 字样或任何 JSON。\n"
362+
"示例输出:\n"
363+
"您好!很高兴见到您。\n"
364+
f"{STREAM_META_MARKER}{{\"emotion\":\"happy\",\"action\":\"wave\"}}"
365+
)
366+
367+
def _split_stream_chunk(self, buffer: str) -> tuple:
368+
"""从 buffer 分离可安全 yield 的文本和 marker 后的 meta。
369+
370+
返回 (safe_text, remaining_buffer, marker_found)。
371+
- 若 buffer 含完整 marker:safe_text = marker 前内容,
372+
remaining_buffer = marker 后内容(meta 起始),marker_found = True。
373+
- 若 buffer 末尾是 marker 的前缀(marker 跨 chunk):safe_text = 去掉前缀的部分,
374+
remaining_buffer = 前缀部分,marker_found = False。
375+
- 否则:safe_text = 全部 buffer,remaining_buffer = "",marker_found = False。
376+
"""
377+
pos = buffer.find(STREAM_META_MARKER)
378+
if pos != -1:
379+
return buffer[:pos], buffer[pos + len(STREAM_META_MARKER):], True
380+
381+
# 检查末尾是否 marker 前缀(从长到短,优先 hold 住更多)
382+
for i in range(len(STREAM_META_MARKER) - 1, 0, -1):
383+
if buffer.endswith(STREAM_META_MARKER[:i]):
384+
return buffer[:-i], buffer[-i:], False
385+
386+
return buffer, "", False
387+
388+
def _parse_stream_meta(self, meta_text: str) -> tuple:
389+
"""解析流式 meta JSON,返回 (emotion, action),失败回退 neutral/idle。"""
390+
try:
391+
parsed = json.loads(meta_text.strip())
392+
except (json.JSONDecodeError, AttributeError):
393+
return "neutral", "idle"
394+
395+
emotion = str(parsed.get("emotion", "neutral")).strip() or "neutral"
396+
action = str(parsed.get("action", "idle")).strip() or "idle"
397+
398+
if emotion not in _VALID_EMOTIONS:
399+
emotion = "neutral"
400+
if action not in _VALID_ACTIONS:
401+
action = "idle"
402+
403+
return emotion, action
404+
295405
def _build_messages(
296406
self,
297407
session_id: Optional[str],

examples/backend-python/tests/test_dialogue_service.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,5 +153,76 @@ async def test_sessions_are_isolated(self) -> None:
153153
self.assertEqual(len(self.service.get_session_history("session-b")), 2)
154154

155155

156+
class CharacterPresetTest(unittest.IsolatedAsyncioTestCase):
157+
"""Tests for character preset system prompt selection."""
158+
159+
def setUp(self) -> None:
160+
self.original_api_key = os.environ.get("OPENAI_API_KEY")
161+
os.environ["OPENAI_API_KEY"] = "test-key"
162+
get_settings.cache_clear()
163+
self.service = DialogueService()
164+
165+
def tearDown(self) -> None:
166+
get_settings.cache_clear()
167+
if self.original_api_key is None:
168+
os.environ.pop("OPENAI_API_KEY", None)
169+
else:
170+
os.environ["OPENAI_API_KEY"] = self.original_api_key
171+
172+
async def test_character_id_selects_matching_prompt(self) -> None:
173+
captured: list[dict[str, str]] = []
174+
175+
async def fake_call(messages: list[dict[str, str]]) -> dict:
176+
captured.extend(messages)
177+
return {"choices": [{"message": {"content": json.dumps({
178+
"replyText": "ok", "emotion": "neutral", "action": "idle"
179+
})}}]}
180+
181+
self.service._call_llm = fake_call # type: ignore[method-assign]
182+
183+
await self.service.generate_reply(
184+
"test", session_id="c1", meta={"characterId": "serious-advisor"}
185+
)
186+
187+
system_msg = captured[0]["content"]
188+
self.assertIn("稳重", system_msg)
189+
self.assertNotIn("俏皮", system_msg)
190+
191+
async def test_unknown_character_id_falls_back_to_default(self) -> None:
192+
captured: list[dict[str, str]] = []
193+
194+
async def fake_call(messages: list[dict[str, str]]) -> dict:
195+
captured.extend(messages)
196+
return {"choices": [{"message": {"content": json.dumps({
197+
"replyText": "ok", "emotion": "neutral", "action": "idle"
198+
})}}]}
199+
200+
self.service._call_llm = fake_call # type: ignore[method-assign]
201+
202+
await self.service.generate_reply(
203+
"test", session_id="c2", meta={"characterId": "nonexistent"}
204+
)
205+
206+
system_msg = captured[0]["content"]
207+
# 默认 lively-assistant 的特征词
208+
self.assertIn("活泼", system_msg)
209+
210+
async def test_no_character_id_uses_default(self) -> None:
211+
captured: list[dict[str, str]] = []
212+
213+
async def fake_call(messages: list[dict[str, str]]) -> dict:
214+
captured.extend(messages)
215+
return {"choices": [{"message": {"content": json.dumps({
216+
"replyText": "ok", "emotion": "neutral", "action": "idle"
217+
})}}]}
218+
219+
self.service._call_llm = fake_call # type: ignore[method-assign]
220+
221+
await self.service.generate_reply("test", session_id="c3")
222+
223+
system_msg = captured[0]["content"]
224+
self.assertIn("活泼", system_msg)
225+
226+
156227
if __name__ == "__main__":
157228
unittest.main()

0 commit comments

Comments
 (0)