diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py
index f912f97..105598a 100644
--- a/api/app/agents/chat/interview_agent.py
+++ b/api/app/agents/chat/interview_agent.py
@@ -24,6 +24,7 @@ from app.agents.chat.prompts_conversation import (
SLOT_NAME_MAP,
SLOT_NAME_MAP_EN,
get_opening_prompt,
+ get_re_greeting_prompt,
)
from app.agents.chat.reply_limits import (
nonempty_segments_or_fallback,
@@ -69,6 +70,16 @@ def _opening_fallback_for(language: str) -> str:
return _OPENING_FALLBACK_EN if language == "en" else _OPENING_FALLBACK_ZH
+_RE_GREETING_FALLBACK_ZH = "上次聊到的事我还记着,今天想继续往下讲讲吗?"
+_RE_GREETING_FALLBACK_EN = (
+ "I still remember what we touched on last time — want to keep going today?"
+)
+
+
+def _re_greeting_fallback_for(language: str) -> str:
+ return _RE_GREETING_FALLBACK_EN if language == "en" else _RE_GREETING_FALLBACK_ZH
+
+
# 仅在「重复问句守卫」把正文削成单句兜底时追加二次 system,只多调一次模型。
_DUPLICATE_GUARD_LLM_RETRY_SYSTEM_APPENDIX = """## 二次生成(纠偏)
上一版模型输出因包含与「最近已问过的问题」或「已确认事实」重复的问句,已被系统弃用。请**重新写一整条回复**:
@@ -548,3 +559,123 @@ class InterviewAgent:
except Exception as e:
logger.error("生成开场白失败: {}", e, exc_info=True)
return [_opening_fallback_for(language)]
+
+ async def generate_re_greeting_message(
+ self,
+ conversation_id: str,
+ memoir_state: MemoirStateSchema,
+ idle_hours: float,
+ user_profile_context: str = "",
+ background_voice: str = "default",
+ occupation: str = "",
+ profile_birth_year: Optional[int] = None,
+ profile_era_place: str = "",
+ language: str = "zh",
+ ) -> List[str]:
+ """老对话回访问候:用户带着已有历史回到对话时,AI 主动做承接式开场。
+
+ 与 generate_opening_message 的差异:prompt 明确告知有历史 + 距上次的时间感受,
+ 要求轻轻引用历史里的具体细节,不能用首次见面式硬开场。
+ """
+ if not self.llm:
+ return [_re_greeting_fallback_for(language)]
+ try:
+ narrative_state = narrative_coverage_state(memoir_state)
+ control_state = interview_control_state(memoir_state)
+ empty_slots = control_state.prompt_empty_slots_for_stage(
+ narrative_state, memoir_state.current_stage
+ )
+ slot_table = SLOT_NAME_MAP_EN if language == "en" else SLOT_NAME_MAP
+ empty_slots_readable = [slot_table.get(s, s) for s in empty_slots]
+ persona = normalize_interview_persona(settings.chat_interview_persona)
+ prompt = get_re_greeting_prompt(
+ current_stage=memoir_state.current_stage,
+ empty_slots_readable=empty_slots_readable,
+ user_profile_context=user_profile_context,
+ persona=persona,
+ background_voice=background_voice,
+ occupation=occupation,
+ profile_birth_year=profile_birth_year,
+ profile_era_place=profile_era_place,
+ idle_hours=idle_hours,
+ language=language,
+ )
+ hw = await get_history_with_window(
+ conversation_id,
+ max_pairs=settings.chat_history_max_pairs,
+ max_chars=settings.chat_history_max_chars,
+ )
+ messages: List[Any] = [SystemMessage(content=prompt)]
+ messages.extend(hw.window)
+ re_greet_tail = (
+ "(用户回到这个已有历史的对话,还没说话。"
+ "请基于上文做温和的承接式回访问候。)"
+ if language != "en"
+ else (
+ "(The user returned to this conversation with prior history and has not spoken yet. "
+ "Give a gentle, grounded re-greeting based on the conversation above.)"
+ )
+ )
+ messages.append(HumanMessage(content=re_greet_tail))
+ log_agent_payload(
+ logger,
+ "InterviewAgent.re_greeting.prompt",
+ format_history_string(
+ messages,
+ omit_system_body=settings.agent_log_omit_system_message_body,
+ ),
+ )
+ re_greet_llm = self.llm.bind(
+ max_tokens=settings.chat_opening_max_tokens,
+ temperature=float(settings.chat_interview_temperature),
+ )
+ llm_t0 = time.perf_counter()
+ with agent_span(
+ logger,
+ "InterviewAgent.re_greeting.llm",
+ conversation_id=conversation_id,
+ ):
+ logger.info(
+ "event=chat_prompt_built agent=InterviewAgent.generate_re_greeting_message "
+ "prompt_chars={} history_pairs_total={} history_pairs_windowed={} idle_hours={:.2f}",
+ _message_contents_char_count(messages),
+ hw.turn_total,
+ len(hw.window) // 2,
+ idle_hours,
+ )
+ response = await re_greet_llm.ainvoke(messages)
+ logger.info(
+ "event=chat_llm_done agent=InterviewAgent.generate_re_greeting_message "
+ "response_latency_ms={:.2f}",
+ (time.perf_counter() - llm_t0) * 1000,
+ )
+ response_text = (
+ response.content if hasattr(response, "content") else str(response)
+ )
+ log_agent_payload(
+ logger, "InterviewAgent.re_greeting.raw_response", response_text
+ )
+ raw_list = segments_from_llm_response(response_text, max_segments=2)
+ if not raw_list:
+ raw_list = [response_text.strip()]
+ max_chars = int(settings.chat_interview_max_chars_per_segment)
+ out = truncate_chat_segments(
+ raw_list,
+ max_segments=2,
+ max_chars_per_segment=max_chars,
+ )
+ log_agent_summary(
+ logger,
+ "InterviewAgent.re_greeting segments={} conversation_id={} idle_hours={:.2f}",
+ len(out),
+ conversation_id,
+ idle_hours,
+ )
+ segments = out if out else [response_text.strip()[:max_chars]]
+ return nonempty_segments_or_fallback(
+ segments,
+ fallback=_re_greeting_fallback_for(language),
+ )
+ except Exception as e:
+ logger.error("生成回访问候失败: {}", e, exc_info=True)
+ return [_re_greeting_fallback_for(language)]
diff --git a/api/app/agents/chat/interview_turn_plan.py b/api/app/agents/chat/interview_turn_plan.py
index 71e6dfc..47587c7 100644
--- a/api/app/agents/chat/interview_turn_plan.py
+++ b/api/app/agents/chat/interview_turn_plan.py
@@ -14,7 +14,7 @@ from dataclasses import dataclass
from typing import Literal
from app.agents.chat.prompts_conversation import SLOT_NAME_MAP
-from app.agents.stage_constants import STAGE_SLOT_KEYS
+from app.agents.stage_constants import STAGE_KEYWORD_WEIGHTS, STAGE_SLOT_KEYS
InterviewTurnMode = Literal[
"emotion_first",
@@ -66,6 +66,7 @@ class InterviewTurnPlan:
secondary_focus: FocusPrimary | None = None
focus_summary: str = ""
focus_source: FocusSource = "rule"
+ low_information_reply: bool = False
# ---- 语义属性:供 prompt_layers / interview_agent 等调用方消费,禁止重复立法 ----
@@ -323,6 +324,17 @@ _ASSISTANT_IDENTITY_QUESTION_MARKERS: tuple[str, ...] = (
"你也是",
)
+_LOW_INFORMATION_REPLY_MAX_CHARS = 8
+_LOW_INFORMATION_REPLY_CHARS: frozenset[str] = frozenset(
+ # 这不是短语白名单,而是一组低信息的应声/确认/语气字符。
+ # 只要短回复里出现不在此集合中的字,就会被视为有潜在叙事信号。
+ "嗯唔呃哦噢喔啊呀呢嘛吧哈"
+ "对是的了好行可允许以"
+ "没错确实不否无有还太很挺就"
+ "什么这样那样当当然"
+)
+_ACK_STRIP_CHARS = " \t\r\n,。!?!?、,.;;::~~…"
+
def _is_emotion_heavy(text: str) -> bool:
t = (text or "").strip()
@@ -351,6 +363,32 @@ def _is_ambiguous_or_needs_slow_pace(text: str) -> bool:
return False
+def _normalized_short_reply(text: str) -> str:
+ return "".join(ch for ch in (text or "").strip() if ch not in _ACK_STRIP_CHARS)
+
+
+def _has_substantive_short_reply_signal(compact: str) -> bool:
+ """短回复里的叙事实质信号:地点/人事词、阶段关键词、年份数字等。"""
+ if any(ch.isdigit() for ch in compact):
+ return True
+ if any(("a" <= ch.lower() <= "z") for ch in compact):
+ return True
+ for weighted_keywords in STAGE_KEYWORD_WEIGHTS.values():
+ if any(keyword and keyword in compact for keyword, _ in weighted_keywords):
+ return True
+ return any(ch not in _LOW_INFORMATION_REPLY_CHARS for ch in compact)
+
+
+def _is_low_information_reply(text: str) -> bool:
+ """识别短、低信息、没有新增叙事素材的确认/应声回复。"""
+ compact = _normalized_short_reply(text)
+ if not compact:
+ return False
+ if len(compact) > _LOW_INFORMATION_REPLY_MAX_CHARS:
+ return False
+ return not _has_substantive_short_reply_signal(compact)
+
+
def _is_too_vague_for_memoir_push(text: str) -> bool:
"""过短或仍含糊时,不进入 memoir_push。"""
t = (text or "").strip()
@@ -396,6 +434,7 @@ def plan_interview_turn(
)
um = (normalized_user_message or "").strip()
asks_assistant_identity = _is_asking_assistant_identity_or_life(um)
+ low_information_reply = _is_low_information_reply(um)
reply_shape: ReplyShape = "flexible"
if any(
k in um
@@ -403,6 +442,45 @@ def plan_interview_turn(
):
reply_shape = "ack_then_question"
+ if low_information_reply:
+ slot = primary_empty_slot(current_stage, empty_slots)
+ if slot:
+ readable = SLOT_NAME_MAP.get(slot, slot)
+ return InterviewTurnPlan(
+ mode="memoir_push",
+ anchor_slot_key=slot,
+ anchor_slot_readable=readable,
+ anchor_snippet=snippet,
+ anchor_source_kind=anchor_source_kind,
+ assistant_identity_question=asks_assistant_identity,
+ memory_usage=mem_use,
+ reply_shape="ack_then_question",
+ primary_focus=_focus_primary_for_mode("memoir_push"),
+ focus_summary=(
+ f"用户只做了简短确认;短接一句后,不澄清“{um}”,"
+ f"主动从「{readable}」打开一个具体、好回答的新回忆话题"
+ ),
+ focus_source="rule",
+ low_information_reply=True,
+ )
+ return InterviewTurnPlan(
+ mode="follow_user_only",
+ anchor_slot_key=None,
+ anchor_slot_readable="(本阶段主要叙述槽已有素材)请回到上文最近的人/事/地方或情绪线,主动打开一个纵深问题",
+ anchor_snippet=snippet,
+ anchor_source_kind=anchor_source_kind,
+ assistant_identity_question=asks_assistant_identity,
+ memory_usage=mem_use,
+ reply_shape="ack_then_question",
+ primary_focus=_focus_primary_for_mode("follow_user_only"),
+ focus_summary=(
+ f"用户只做了简短确认;短接一句后,不澄清“{um}”,"
+ "回到上文最近的具体线索,主动递一个新的回忆追问"
+ ),
+ focus_source="rule",
+ low_information_reply=True,
+ )
+
if _is_emotion_heavy(normalized_user_message):
slot = primary_empty_slot(current_stage, empty_slots)
readable = (
@@ -534,6 +612,17 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
else:
shape_block = ""
+ ack_block = ""
+ if plan.low_information_reply:
+ ack_block = (
+ "- **低信息短回复处理**:用户本轮只是简短确认、应声或泛泛回应,没有新增叙事素材。"
+ "不要把这个短答本身当成需要澄清的内容,不要反复追问「你是说……吗」,"
+ "也不要停在原地等用户继续补充。\n"
+ "- 先用半句自然接住,再主动从「主追问方向」、上文最近的具体人/事/地方,"
+ "或过往记忆线索里挑一个**具体、好回答**的新回忆话题;若挂钩线索为空,"
+ "允许直接借当前阶段未聊方向起问,但禁止编造用户没说过的细节。\n"
+ )
+
if plan.mode == "emotion_first":
mode_rules = (
"- **情绪优先**:本轮以承接、并肩与安全感为主,**不要**为推进「叙述槽大纲」而追加信息搜集型追问。\n"
@@ -557,13 +646,29 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
"- **跟话头**:本轮禁止问卷式首开、禁止重启式盘点;顺着用户刚展开的画面、人物或情绪自然往下。\n"
"- 若带问句,最多**一个**,且必须**从用户原词或下面摘录**长出来,禁止空泛「还有吗」。"
)
+ if plan.low_information_reply:
+ mode_rules = (
+ "- **跟话头 + 主动续话**:用户本轮只是简短确认,没有新素材;"
+ "不要围着短答本身澄清,也不要重复上一问等对方补充。\n"
+ "- 若带问句,最多**一个**,优先从上文最近的具体人/事/地方或下面摘录长出来;"
+ "若无可用摘录,就从当前阶段已聊内容的纵深处打开一个新回忆话题。"
+ )
else:
- mode_rules = (
- "- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
- " 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
- "- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
- "仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
- )
+ if plan.low_information_reply:
+ mode_rules = (
+ "- **回忆推进(memoir_push)**:用户本轮只是简短确认,"
+ "对用户可见回复中须有**恰好一个**开放式回忆追问,主动把话头往下递。\n"
+ " 追问可挂住**上文最近具体细节**、**主追问方向**或**挂钩摘录**;"
+ "不要要求从低信息短答里抽词。\n"
+ "- 禁止用日常寒暄替代该追问;仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
+ )
+ else:
+ mode_rules = (
+ "- **回忆推进(memoir_push)**:对用户可见回复中须有**恰好一个**开放式回忆追问,\n"
+ " 且意图明显在补足下面「主追问方向」;问句必须挂住**用户本轮原词**或**挂钩摘录**(二者至少其一,且**优先原词**)。\n"
+ "- **禁止**把用户仍含糊的表达改写成确定事实或关系;禁止用日常寒暄替代该追问;"
+ "仍遵守全篇「最多一个问句」「禁止晚会硬切」。"
+ )
focus_block = _focus_directive_lines(plan)
@@ -576,7 +681,7 @@ def format_interview_turn_directive_block(plan: InterviewTurnPlan) -> str:
return f"""## 本轮编排指令(硬规则,优先于后文一般性建议)
{mode_rules}
-{focus_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
+{focus_block}{ack_block}{mem_block}{perspective_block}{shape_block}- **主追问方向(叙述槽)**:{plan.anchor_slot_readable}
- **挂钩线索**{anchor_label}:{snippet_line}
"""
diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py
index 89d406e..dce776e 100644
--- a/api/app/agents/chat/orchestrator.py
+++ b/api/app/agents/chat/orchestrator.py
@@ -508,3 +508,28 @@ class ChatOrchestrator:
profile_era_place=profile_era_place,
language=language,
)
+
+ async def generate_re_greeting_message(
+ self,
+ conversation_id: str,
+ memoir_state: MemoirStateSchema,
+ idle_hours: float,
+ user_profile_context: str = "",
+ background_voice: str = "default",
+ occupation: str = "",
+ profile_birth_year: Optional[int] = None,
+ profile_era_place: str = "",
+ language: str = "zh",
+ ) -> List[str]:
+ """委托 InterviewAgent 生成老对话回访问候(持久化由调用方负责)。"""
+ return await self.interview_agent.generate_re_greeting_message(
+ conversation_id=conversation_id,
+ memoir_state=memoir_state,
+ idle_hours=idle_hours,
+ user_profile_context=user_profile_context,
+ background_voice=background_voice,
+ occupation=occupation,
+ profile_birth_year=profile_birth_year,
+ profile_era_place=profile_era_place,
+ language=language,
+ )
diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py
index 8a0286f..1081dd9 100644
--- a/api/app/agents/chat/prompts_conversation.py
+++ b/api/app/agents/chat/prompts_conversation.py
@@ -505,10 +505,259 @@ def get_guided_conversation_prompt(
# 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。
+def _get_re_greeting_prompt_en(
+ current_stage: str,
+ empty_slots_readable: List[str],
+ user_profile_context: str = "",
+ background_voice: str = "default",
+ occupation: str = "",
+ idle_hours: float = 6.0,
+) -> str:
+ """English re-greeting; mirrors Chinese structure with lighter persona nuance."""
+ stage_name = stage_display_name(current_stage, language="en")
+ bv = normalize_background_voice(background_voice)
+
+ if idle_hours >= 168:
+ idle_phrase = "it's been quite a while"
+ elif idle_hours >= 48:
+ idle_phrase = "it's been several days"
+ elif idle_hours >= 20:
+ idle_phrase = "about a day has passed"
+ else:
+ idle_phrase = "only a little time has passed"
+
+ if empty_slots_readable:
+ topics_str = ", ".join(empty_slots_readable[:4])
+ topic_hint = (
+ f"## You can still explore ({stage_name})\n"
+ f"If picking up last time feels hard, gently land on one of these: {topics_str}."
+ )
+ else:
+ topic_hint = (
+ f"## Current focus ({stage_name})\n"
+ "Most beats here are covered; prefer returning to a concrete person, place, or moment from before."
+ )
+
+ if bv == "cadre":
+ style_note = "## Tone\nSteady and respectful; no slick slogans or stacked parallelisms."
+ elif bv == "military":
+ style_note = "## Tone\nCrisp and appropriate; no dramatic military flourishes."
+ else:
+ style_note = "## Tone\nLike an old friend you have not seen in a bit: warm, restrained, no preachy lists."
+
+ profile_lines: List[str] = []
+ if user_profile_context.strip():
+ profile_lines.append(user_profile_context.strip())
+ occ = get_occupation_chat_hint(occupation, background_voice)
+ if occ:
+ profile_lines.append(occ)
+ profile_section = ""
+ if profile_lines:
+ profile_section = "## About the user\n" + "\n".join(profile_lines) + "\n"
+
+ head = (
+ f'You are "{AGENT_NAME_EN}" — a host-style confidant. The user returns with **existing chat history** and has **not spoken yet** — you speak first. '
+ f"Context: {idle_phrase} since their last message.\n\n"
+ "**Job**: offer a warm reopening that shows you remember something specific they shared, then a light, memory-oriented hook you hand back to them.\n\n"
+ "## Requirements\n"
+ "1. **Must** reference one or two concrete details from the history (a person, place, object, or beat) — do not genericize with \"we had a great chat last time.\"\n"
+ "2. **Do not** reuse a brand-new-chat hello; something like \"Last time you mentioned X — want to continue?\" fits better.\n"
+ "3. The hook should be **specific, easy to answer, and visual**, tied to life memory — not \"how have you been\" small talk.\n"
+ "4. If history offers no usable threads, pick a small landing from the stage hints; still avoid vague interrogation.\n"
+ "5. Keep it short: two or three sentences, no long paragraphs.\n"
+ )
+
+ return f"""{head}{profile_section}{topic_hint}
+{style_note}
+## Format
+- Use `[SPLIT]` for at most two short bubbles, or one bubble with reopening + hook.
+- {chat_output_rules_en()} Do not write the user's answer for them.
+
+Output (spoken English only, no Markdown):"""
+
+
+def get_re_greeting_prompt(
+ current_stage: str,
+ empty_slots_readable: List[str],
+ user_profile_context: str = "",
+ persona: str = "default",
+ background_voice: str = "default",
+ occupation: str = "",
+ profile_birth_year: Optional[int] = None,
+ profile_era_place: str = "",
+ idle_hours: float = 6.0,
+ language: str = "zh",
+) -> str:
+ """老对话回访问候提示词:用户带着已有历史回到对话,AI 先开口做承接式问候。"""
+ if language == "en":
+ return _get_re_greeting_prompt_en(
+ current_stage=current_stage,
+ empty_slots_readable=empty_slots_readable,
+ user_profile_context=user_profile_context,
+ background_voice=background_voice,
+ occupation=occupation,
+ idle_hours=idle_hours,
+ )
+ stage_name = stage_display_name(current_stage, language="zh")
+ bv = normalize_background_voice(background_voice)
+
+ if idle_hours >= 168:
+ idle_phrase = "好一阵子没聊了"
+ elif idle_hours >= 48:
+ idle_phrase = "好几天没聊了"
+ elif idle_hours >= 20:
+ idle_phrase = "隔了一天"
+ else:
+ idle_phrase = "今天又见面"
+
+ if empty_slots_readable:
+ topics_str = "、".join(empty_slots_readable[:4])
+ topic_hint = (
+ f"## 当前阶段({stage_name})还可以聊\n"
+ f"如果上次聊过的事不便直接接续,可从这些方向里挑一个落点:{topics_str}。"
+ )
+ else:
+ topic_hint = (
+ f"## 当前阶段({stage_name})\n"
+ "这一阶段主要话题已有覆盖;优先回到上次聊过的人/事/地方,做温和的承接。"
+ )
+
+ if bv == "cadre":
+ style_note = "## 语气\n稳重、敬语适度;问候不油滑、不堆排比。"
+ elif bv == "military":
+ style_note = "## 语气\n简洁、得体;不过度起势、不堆军事辞藻。"
+ else:
+ style_note = "## 语气\n像许久未见的老朋友,温暖而克制;不要排比、不要长段文学描写。"
+
+ profile_lines: List[str] = []
+ if user_profile_context.strip():
+ profile_lines.append(user_profile_context.strip())
+ occ = get_occupation_chat_hint(occupation, background_voice)
+ if occ:
+ profile_lines.append(occ)
+ profile_section = ""
+ if profile_lines:
+ profile_section = "## 用户信息\n" + "\n".join(profile_lines) + "\n"
+
+ persona_key = normalize_interview_persona(persona)
+ persona_tone = get_interview_persona_tone_hint(persona_key)
+ voice_tone = get_background_voice_tone_hint(background_voice)
+ tone_bits = [t for t in (persona_tone, voice_tone) if t]
+ tone_paragraph = ""
+ if tone_bits:
+ tone_paragraph = " " + " ".join(tone_bits) + "\n\n"
+
+ head = (
+ "你是「岁月知己」——主持式知己。用户带着**已有的对话历史**回到这里,**还没说话**,请你先开口。"
+ f"语境:距上次消息已经{idle_phrase}。"
+ "**职责**:用一句温暖的承接打招呼,让对方感到「我记得你上次说过的事」,再轻轻递上一个**回忆向**的钩子,把话头交还给他。\n\n"
+ "## 要求\n"
+ "1. **必须**轻轻引用历史里的具体人/事/地方/物件做承接(一两个细节即可,不要罗列),不要空喊「上次聊得很好」。\n"
+ "2. **不要**用与刚开新对话相同的「您好/你好呀」式硬开场;像「上次你说到 X,今天想接着讲讲吗?」更合适。\n"
+ "3. 钩子要**具体、好回答、有画面感**,落在人生回忆里;不要问「最近怎么样」「今天忙吗」这种纯社交寒暄。\n"
+ "4. 若历史里没有可用细节,可从「当前阶段还可以聊」里挑一个轻巧落点;仍要避免泛泛盘问。\n"
+ "5. 简短:两三句内,不要排比、不要长段。\n"
+ )
+
+ return f"""{head}{tone_paragraph}{profile_section}{topic_hint}
+{style_note}
+## 格式
+- 可用 [SPLIT] 分成最多 2 条;或一条里「承接 + 钩子」。
+- {chat_output_rules()} 不要替用户编回答。
+
+直接输出(仅自然口语,无 Markdown):"""
+
+
+_STAGE_TOPIC_CHIP_BANK: Dict[str, List[tuple[str, str]]] = {
+ "childhood": [
+ ("place", "童年长大的地方"),
+ ("people", "童年里重要的人"),
+ ("daily_life", "童年的一天"),
+ ("turning_event", "童年最难忘的一件事"),
+ ("emotion", "童年最深的感受"),
+ ],
+ "education": [
+ ("school", "学生时代的学校"),
+ ("city", "求学的城市"),
+ ("motivation", "读书时的动力"),
+ ("challenge", "求学路上的难关"),
+ ("change", "求学带来的变化"),
+ ],
+ "career": [
+ ("job", "做过的工作"),
+ ("environment", "工作的环境"),
+ ("decision", "职业里的关键决定"),
+ ("pressure", "工作中的压力"),
+ ("growth", "职业上的成长"),
+ ],
+ "family": [
+ ("relationship", "家人之间的关系"),
+ ("conflict", "家里的矛盾与化解"),
+ ("support", "家人之间的相互支持"),
+ ("responsibility", "肩上的家庭责任"),
+ ],
+ "later_life": [
+ ("value", "现在最看重的事"),
+ ("regret", "心里的遗憾"),
+ ("pride", "最骄傲的事"),
+ ("lesson", "想留下的人生经验"),
+ ],
+}
+
+
+def build_topic_chips(
+ current_stage: str,
+ empty_slots: List[str],
+ *,
+ max_chips: int = 4,
+) -> List[Dict[str, str]]:
+ """根据当前阶段与空 slot 列表生成 quick-start 话题 chips。
+
+ 返回结构:[{"id": slot_key, "label": 短标签, "text": 用户点击后发出的句子}]
+ """
+ stage_bank = _STAGE_TOPIC_CHIP_BANK.get(current_stage) or []
+ seen: set[str] = set()
+ chips: List[Dict[str, str]] = []
+
+ # 优先从「当前阶段空 slot」挑选(与开场提问方向一致)
+ empty_set = {s for s in empty_slots if s}
+ for slot_key, label in stage_bank:
+ if slot_key in empty_set and slot_key not in seen:
+ chips.append(
+ {
+ "id": slot_key,
+ "label": label,
+ "text": f"我想聊聊{label}",
+ }
+ )
+ seen.add(slot_key)
+ if len(chips) >= max_chips:
+ return chips
+
+ # 不足则用阶段默认话题补齐
+ for slot_key, label in stage_bank:
+ if slot_key in seen:
+ continue
+ chips.append(
+ {
+ "id": slot_key,
+ "label": label,
+ "text": f"我想聊聊{label}",
+ }
+ )
+ seen.add(slot_key)
+ if len(chips) >= max_chips:
+ return chips
+
+ return chips
+
+
__all__ = [
"SLOT_NAME_MAP",
"SLOT_NAME_MAP_EN",
"slot_name_map_for",
+ "build_topic_chips",
"get_guided_conversation_prompt",
"get_opening_prompt",
+ "get_re_greeting_prompt",
]
diff --git a/api/app/core/config.py b/api/app/core/config.py
index f4fbcab..6341f26 100644
--- a/api/app/core/config.py
+++ b/api/app/core/config.py
@@ -99,6 +99,12 @@ class Settings(BaseSettings):
chat_reply_planner_llm_enabled: bool = False
chat_reply_planner_max_tokens: int = Field(default=256, ge=64, le=1024)
chat_reply_planner_temperature: float = Field(default=0.2, ge=0.0, le=1.0)
+ # 老对话回访问候:连接时若距上次消息超过该小时数,由 AI 主动发一条承接式开场(自防抖:发完即更新 last_message_at)
+ chat_re_greeting_enabled: bool = True
+ chat_re_greeting_idle_hours: float = Field(default=6.0, ge=0.25, le=240.0)
+ # 话题建议 chips:连接首帧附带 3-4 个 quick-start 话题(来自当前阶段的空 slots)
+ chat_topic_chips_enabled: bool = True
+ chat_topic_chips_max: int = Field(default=4, ge=1, le=8)
# ── Memoir 叙事忠实度检查(FidelityCheckAgent)────────────────
memoir_fidelity_check_enabled: bool = True
diff --git a/api/app/features/conversation/ws/message_types.py b/api/app/features/conversation/ws/message_types.py
index 33b77ac..8f5883d 100644
--- a/api/app/features/conversation/ws/message_types.py
+++ b/api/app/features/conversation/ws/message_types.py
@@ -22,4 +22,5 @@ class MessageType(str, Enum):
PONG = "pong"
END_CONVERSATION = "end_conversation"
MEMOIR_UPDATE = "memoir_update"
+ TOPIC_SUGGESTIONS = "topic_suggestions"
ERROR = "error"
diff --git a/api/app/features/conversation/ws/router.py b/api/app/features/conversation/ws/router.py
index cb57568..c1dbb45 100644
--- a/api/app/features/conversation/ws/router.py
+++ b/api/app/features/conversation/ws/router.py
@@ -11,8 +11,14 @@ from fastapi import WebSocket, WebSocketDisconnect, status
from starlette.websockets import WebSocketState
from app.agents.chat.background_voice import infer_background_voice
+from app.agents.chat.prompts_conversation import build_topic_chips
from app.agents.chat.prompts_profile import format_user_profile_context
from app.agents.stage_constants import STAGE_TO_ORDER
+from app.agents.state_schema import (
+ interview_control_state,
+ narrative_coverage_state,
+)
+from app.core.config import settings
from app.core.db import AsyncSessionLocal
from app.core.dependencies import get_asr_provider
from app.core.logging import get_logger
@@ -45,6 +51,18 @@ from app.features.user.models import User
logger = get_logger(__name__)
+def _idle_hours_since(ts) -> float | None:
+ """计算距 ts 的小时数;ts 为 None 或非 datetime 时返回 None。"""
+ if ts is None:
+ return None
+ if not isinstance(ts, datetime):
+ return None
+ if ts.tzinfo is None:
+ ts = ts.replace(tzinfo=timezone.utc)
+ delta = datetime.now(timezone.utc) - ts
+ return max(0.0, delta.total_seconds() / 3600.0)
+
+
async def websocket_endpoint(
websocket: WebSocket,
conversation_id: str,
@@ -163,6 +181,87 @@ async def websocket_endpoint(
if str(getattr(user, "language_preference", "zh") or "zh").lower() == "en"
else "zh"
)
+
+ async def _stream_ai_only_messages(
+ texts: list[str], log_label: str
+ ) -> None:
+ """统一:把一组 AI 消息落库并按 [SPLIT] 分段下发。"""
+ if not texts:
+ return
+ ai_msg_id = await ConversationHistoryStore(db).record_ai_only_turn(
+ conversation_id, texts
+ )
+ if not ai_msg_id:
+ return
+ total_n = len(texts)
+ for i, text in enumerate(texts):
+ await manager.send_message(
+ conversation_id,
+ {
+ "type": MessageType.AGENT_RESPONSE,
+ "conversation_id": conversation_id,
+ "data": {
+ "text": text,
+ "index": i,
+ "total": total_n,
+ "assistant_message_id": ai_msg_id,
+ },
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ },
+ )
+ if i < total_n - 1:
+ await asyncio.sleep(0.5)
+ logger.info(
+ "event=ws_auto_ai_sent label={} conversation_id={} segments={}",
+ log_label,
+ conversation_id,
+ total_n,
+ )
+
+ async def _maybe_send_topic_chips(reason: str) -> None:
+ """根据当前阶段空 slot 生成 quick-start 话题 chips;失败静默。"""
+ if not settings.chat_topic_chips_enabled:
+ return
+ # 资料未齐时不送 chips:profile 收集走另一条流程,chips 反而噪音
+ if get_missing_profile_fields(user):
+ return
+ try:
+ narrative_state = narrative_coverage_state(memoir_state)
+ control_state = interview_control_state(memoir_state)
+ empty_slots = control_state.prompt_empty_slots_for_stage(
+ narrative_state, memoir_state.current_stage
+ )
+ chips = build_topic_chips(
+ memoir_state.current_stage,
+ empty_slots,
+ max_chips=settings.chat_topic_chips_max,
+ )
+ if not chips:
+ return
+ await manager.send_message(
+ conversation_id,
+ {
+ "type": MessageType.TOPIC_SUGGESTIONS,
+ "conversation_id": conversation_id,
+ "data": {
+ "reason": reason,
+ "stage": memoir_state.current_stage,
+ "suggestions": chips,
+ },
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ },
+ )
+ logger.info(
+ "event=ws_topic_chips_sent reason={} conversation_id={} "
+ "stage={} count={}",
+ reason,
+ conversation_id,
+ memoir_state.current_stage,
+ len(chips),
+ )
+ except Exception as e:
+ logger.warning("发送话题 chips 失败: {}", e)
+
if not history:
missing_profile = get_missing_profile_fields(user)
if missing_profile:
@@ -173,35 +272,13 @@ async def websocket_endpoint(
nickname=user.nickname or "",
language=user_language,
)
- ai_msg_id = await ConversationHistoryStore(
- db
- ).record_ai_only_turn(conversation_id, greetings)
- if ai_msg_id:
- ng = len(greetings)
- for i, text in enumerate(greetings):
- await manager.send_message(
- conversation_id,
- {
- "type": MessageType.AGENT_RESPONSE,
- "conversation_id": conversation_id,
- "data": {
- "text": text,
- "index": i,
- "total": ng,
- "assistant_message_id": ai_msg_id,
- },
- "timestamp": datetime.now(
- timezone.utc
- ).isoformat(),
- },
- )
- if i < ng - 1:
- await asyncio.sleep(0.5)
+ await _stream_ai_only_messages(
+ greetings, log_label="profile_greeting"
+ )
except Exception as e:
logger.exception("发送资料收集开场白失败: {}", e)
else:
try:
- state = memoir_state
user_profile_context = format_user_profile_context(
birth_year=user.birth_year,
birth_place=user.birth_place,
@@ -213,7 +290,7 @@ async def websocket_endpoint(
opening_messages = (
await chat_orchestrator.generate_opening_message(
conversation_id=conversation_id,
- memoir_state=state,
+ memoir_state=memoir_state,
user_profile_context=user_profile_context,
background_voice=infer_background_voice(
user.occupation
@@ -224,32 +301,62 @@ async def websocket_endpoint(
language=user_language,
)
)
- ai_msg_id = await ConversationHistoryStore(
- db
- ).record_ai_only_turn(conversation_id, opening_messages)
- if ai_msg_id:
- no = len(opening_messages)
- for i, text in enumerate(opening_messages):
- await manager.send_message(
- conversation_id,
- {
- "type": MessageType.AGENT_RESPONSE,
- "conversation_id": conversation_id,
- "data": {
- "text": text,
- "index": i,
- "total": no,
- "assistant_message_id": ai_msg_id,
- },
- "timestamp": datetime.now(
- timezone.utc
- ).isoformat(),
- },
- )
- if i < no - 1:
- await asyncio.sleep(0.5)
+ await _stream_ai_only_messages(
+ opening_messages, log_label="opening"
+ )
+ await _maybe_send_topic_chips(reason="opening")
except Exception as e:
logger.exception("发送空对话开场白失败: {}", e)
+ else:
+ # 历史非空:判断是否需要回访问候(距上次消息超过阈值)
+ idle_hours = _idle_hours_since(conversation.last_message_at)
+ threshold = float(settings.chat_re_greeting_idle_hours)
+ if (
+ settings.chat_re_greeting_enabled
+ and not get_missing_profile_fields(user)
+ and idle_hours is not None
+ and idle_hours >= threshold
+ ):
+ try:
+ user_profile_context = format_user_profile_context(
+ birth_year=user.birth_year,
+ birth_place=user.birth_place,
+ grew_up_place=user.grew_up_place,
+ occupation=user.occupation,
+ language=user_language,
+ )
+ era_place = (user.grew_up_place or user.birth_place or "") or ""
+ re_greetings = (
+ await chat_orchestrator.generate_re_greeting_message(
+ conversation_id=conversation_id,
+ memoir_state=memoir_state,
+ idle_hours=idle_hours,
+ user_profile_context=user_profile_context,
+ background_voice=infer_background_voice(
+ user.occupation
+ ),
+ occupation=user.occupation or "",
+ profile_birth_year=user.birth_year,
+ profile_era_place=era_place,
+ language=user_language,
+ )
+ )
+ await _stream_ai_only_messages(
+ re_greetings, log_label="re_greeting"
+ )
+ logger.info(
+ "event=ws_re_greeting_emitted conversation_id={} "
+ "idle_hours={:.2f} threshold={:.2f}",
+ conversation_id,
+ idle_hours,
+ threshold,
+ )
+ await _maybe_send_topic_chips(reason="re_greeting")
+ except Exception as e:
+ logger.exception("发送回访问候失败: {}", e)
+ else:
+ # 不触发回访问候时,仍可下发 chips 以减少冷启动门槛
+ await _maybe_send_topic_chips(reason="resume")
while True:
try:
diff --git a/api/development.sh b/api/development.sh
index 27912d1..e99bee7 100755
--- a/api/development.sh
+++ b/api/development.sh
@@ -213,6 +213,66 @@ get_effective_database_url() {
return 1
}
+get_effective_redis_url() {
+ if [[ -n "${REDIS_URL:-}" ]]; then
+ printf '%s\n' "${REDIS_URL}"
+ return 0
+ fi
+
+ if [[ -f "${ROOT_DIR}/.env" ]]; then
+ local line
+ line="$(sed -n 's/^REDIS_URL=//p' "${ROOT_DIR}/.env" | sed -n '1p')"
+ line="${line%\"}"
+ line="${line#\"}"
+ line="${line%\'}"
+ line="${line#\'}"
+ if [[ -n "${line}" ]]; then
+ printf '%s\n' "${line}"
+ return 0
+ fi
+ fi
+
+ return 1
+}
+
+extract_url_port() {
+ local url="$1"
+ local default_port="$2"
+
+ if [[ "${url}" =~ :([0-9]+)(/|\?|$) ]]; then
+ printf '%s\n' "${BASH_REMATCH[1]}"
+ return 0
+ fi
+
+ printf '%s\n' "${default_port}"
+}
+
+wait_host_infra_ready() {
+ local database_url redis_url pg_port redis_port
+
+ if ! database_url="$(get_effective_database_url)"; then
+ print_warn "无法解析 DATABASE_URL,跳过宿主机 PostgreSQL 端口检查"
+ else
+ pg_port="$(extract_url_port "${database_url}" "5432")"
+ if wait_for_tcp_listener "$$" "${pg_port}" 12; then
+ print_ok "宿主机 PostgreSQL 端口已监听 (:${pg_port})"
+ else
+ print_warn "宿主机 PostgreSQL 端口未监听 (:${pg_port});请检查 .env 与 docker-compose.dev.yml 端口映射"
+ fi
+ fi
+
+ if ! redis_url="$(get_effective_redis_url)"; then
+ print_warn "无法解析 REDIS_URL,跳过宿主机 Redis 端口检查"
+ else
+ redis_port="$(extract_url_port "${redis_url}" "6379")"
+ if wait_for_tcp_listener "$$" "${redis_port}" 12; then
+ print_ok "宿主机 Redis 端口已监听 (:${redis_port})"
+ else
+ print_warn "宿主机 Redis 端口未监听 (:${redis_port});请检查 .env 与 docker-compose.dev.yml 端口映射"
+ fi
+ fi
+}
+
warn_database_url_host_pitfall() {
local database_url
local host
@@ -563,6 +623,7 @@ main() {
ensure_venv
ensure_dotenv_from_development
check_env_file
+ wait_host_infra_ready
run_migrations
start_services
diff --git a/api/docker-compose.dev.yml b/api/docker-compose.dev.yml
index 276cfa0..c474fcc 100644
--- a/api/docker-compose.dev.yml
+++ b/api/docker-compose.dev.yml
@@ -11,7 +11,7 @@ services:
image: pgvector/pgvector:pg17
container_name: life-echo-postgres-dev
ports:
- - "127.0.0.1:48291:5432"
+ - "48291:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@@ -30,7 +30,7 @@ services:
image: redis:7-alpine
container_name: life-echo-redis-dev
ports:
- - "127.0.0.1:48307:6379"
+ - "48307:6379"
volumes:
- redis_data_dev:/data
command: redis-server --appendonly yes
diff --git a/api/tests/test_interview_turn_plan.py b/api/tests/test_interview_turn_plan.py
index ab90e7d..46dea10 100644
--- a/api/tests/test_interview_turn_plan.py
+++ b/api/tests/test_interview_turn_plan.py
@@ -181,13 +181,66 @@ def test_plan_clarify_first_when_very_short():
p = plan_interview_turn(
current_stage="childhood",
empty_slots=["place"],
- normalized_user_message="还好吧",
+ normalized_user_message="有点说不清",
memory_evidence_text="",
stage_switched_this_turn=False,
)
assert p.mode == "clarify_first"
+def test_plan_low_information_reply_pushes_next_topic_when_slots_remain():
+ p = plan_interview_turn(
+ current_stage="childhood",
+ empty_slots=["place", "people"],
+ normalized_user_message="嗯。",
+ memory_evidence_text="",
+ stage_switched_this_turn=False,
+ )
+ assert p.mode == "memoir_push"
+ assert p.anchor_slot_key == "place"
+ assert p.reply_shape == "ack_then_question"
+ assert p.low_information_reply is True
+
+
+def test_low_information_directive_asks_for_proactive_topic_not_clarification():
+ p = plan_interview_turn(
+ current_stage="childhood",
+ empty_slots=["place"],
+ normalized_user_message="对",
+ memory_evidence_text="",
+ stage_switched_this_turn=False,
+ )
+ block = format_interview_turn_directive_block(p)
+ assert "低信息短回复处理" in block
+ assert "不要把这个短答本身当成需要澄清的内容" in block
+ assert "恰好一个" in block
+ assert "主动" in block
+
+
+def test_plan_low_information_reply_uses_follow_when_no_slots_remain():
+ p = plan_interview_turn(
+ current_stage="childhood",
+ empty_slots=[],
+ normalized_user_message="是的",
+ memory_evidence_text="",
+ stage_switched_this_turn=False,
+ )
+ assert p.mode == "follow_user_only"
+ assert p.reply_shape == "ack_then_question"
+ assert p.low_information_reply is True
+
+
+def test_short_substantive_reply_is_not_treated_as_low_information():
+ p = plan_interview_turn(
+ current_stage="childhood",
+ empty_slots=["place", "people"],
+ normalized_user_message="上海",
+ memory_evidence_text="",
+ stage_switched_this_turn=False,
+ )
+ assert p.low_information_reply is False
+
+
def test_plan_memoir_push():
p = plan_interview_turn(
current_stage="childhood",
@@ -232,3 +285,4 @@ def test_plan_follow_when_no_empty_slots():
stage_switched_this_turn=False,
)
assert p.mode == "follow_user_only"
+ assert p.low_information_reply is True
diff --git a/app-expo/.env.staging b/app-expo/.env.staging
index b29f0c7..7382083 100644
--- a/app-expo/.env.staging
+++ b/app-expo/.env.staging
@@ -1,3 +1,2 @@
-# 仅 API/WS 基址;TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example)。
-EXPO_PUBLIC_API_URL=http://1.15.29.57:8000
-EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000
+EXPO_PUBLIC_API_URL=http://1.15.29.57:8000/
+EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000/
diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx
index 45a871a..00bb701 100644
--- a/app-expo/src/app/(main)/conversation/[id].tsx
+++ b/app-expo/src/app/(main)/conversation/[id].tsx
@@ -26,6 +26,7 @@ import {
type NativeSyntheticEvent,
Platform,
Pressable,
+ ScrollView,
StyleSheet,
Switch,
Text as RNText,
@@ -55,6 +56,7 @@ import { useThemeColors } from '@/hooks/use-theme-colors';
import { useTypography } from '@/core/typography-context';
import { useMessages, useRealtimeSession } from '@/features/conversation/hooks';
import type { TtsSegmentPayload } from '@/features/conversation/realtime-session';
+import type { TopicSuggestion } from '@/core/ws/types';
import { conversationKeys } from '@/features/conversation/query-keys';
import { useSession } from '@/features/auth/hooks';
import { useProfile } from '@/features/profile/hooks';
@@ -868,6 +870,58 @@ function VoiceRecordButton({
);
}
+function TopicChipsRow({
+ chips,
+ onPressChip,
+ onDismiss,
+ dismissLabel,
+}: {
+ chips: TopicSuggestion[];
+ onPressChip: (text: string) => void;
+ onDismiss: () => void;
+ dismissLabel: string;
+}) {
+ if (!chips || chips.length === 0) return null;
+ return (
+
+
+ {chips.map((chip) => (
+ onPressChip(chip.text)}
+ style={({ pressed }) => [
+ styles.topicChip,
+ pressed && styles.topicChipPressed,
+ ]}
+ accessibilityRole="button"
+ accessibilityLabel={chip.label}
+ >
+
+ {chip.label}
+
+
+ ))}
+ [
+ styles.topicChipDismiss,
+ pressed && styles.topicChipPressed,
+ ]}
+ accessibilityRole="button"
+ accessibilityLabel={dismissLabel}
+ >
+ {dismissLabel}
+
+
+
+ );
+}
+
function ChatInputBar({
value,
onChangeText,
@@ -1377,6 +1431,8 @@ export default function ConversationScreen() {
connectionState,
streamingMessage,
awaitingAssistantReply,
+ topicSuggestions,
+ dismissTopicSuggestions,
sendText,
sendVoiceMessage,
sendTtsCancel,
@@ -1648,6 +1704,38 @@ export default function ConversationScreen() {
scheduleRefocusComposer();
};
+ const handleTopicChipPress = useCallback(
+ (chipText: string) => {
+ const text = chipText.trim();
+ if (!text) return;
+ if (connectionState === 'disconnected') {
+ Alert.alert(t('chatUnavailableTitle'), t('chatUnavailableDisconnected'));
+ return;
+ }
+ if (connectionState === 'connecting') {
+ pendingTextSendRef.current = text;
+ clearConnectingSendTimeout();
+ connectingSendTimeoutRef.current = setTimeout(() => {
+ connectingSendTimeoutRef.current = null;
+ if (pendingTextSendRef.current !== text) return;
+ pendingTextSendRef.current = null;
+ Alert.alert(t('chatUnavailableTitle'), t('chatQueueSendTimeout'));
+ }, CONNECTING_SEND_TIMEOUT_MS);
+ dismissTopicSuggestions();
+ return;
+ }
+ sendText(text);
+ dismissTopicSuggestions();
+ },
+ [
+ connectionState,
+ sendText,
+ dismissTopicSuggestions,
+ clearConnectingSendTimeout,
+ t,
+ ],
+ );
+
/** 仅完全断开时禁用发送/语音;连接中可点发送(排队) */
const composerDisabled = connectionState === 'disconnected';
@@ -1824,6 +1912,12 @@ export default function ConversationScreen() {
) : null}
+
{
+ if (!raw || typeof raw !== 'object') return null;
+ const s = raw as Record;
+ const id = typeof s.id === 'string' ? s.id : '';
+ const label = typeof s.label === 'string' ? s.label : '';
+ const text = typeof s.text === 'string' ? s.text : '';
+ if (!id || !label || !text) return null;
+ return { id, label, text };
+ })
+ .filter((x): x is { id: string; label: string; text: string } => !!x);
+ return {
+ kind: 'topic_suggestions',
+ conversationId: cid,
+ reason: typeof d.reason === 'string' ? d.reason : '',
+ stage: typeof d.stage === 'string' ? d.stage : undefined,
+ suggestions,
+ };
+ }
+
case 'error':
return {
kind: 'session_error',
diff --git a/app-expo/src/core/ws/types.ts b/app-expo/src/core/ws/types.ts
index abd34a2..34e35c3 100644
--- a/app-expo/src/core/ws/types.ts
+++ b/app-expo/src/core/ws/types.ts
@@ -7,6 +7,7 @@ export type ServerMessageType =
| 'tts_audio'
| 'end_conversation'
| 'memoir_update'
+ | 'topic_suggestions'
| 'error';
export type ClientMessageType =
@@ -85,6 +86,22 @@ export interface MemoirUpdatedEvent {
data: Record;
}
+export interface TopicSuggestion {
+ id: string;
+ label: string;
+ /** 用户点击后作为用户文本发送的内容 */
+ text: string;
+}
+
+export interface TopicSuggestionsEvent {
+ kind: 'topic_suggestions';
+ conversationId: string;
+ /** 触发原因:opening | re_greeting | resume */
+ reason: string;
+ stage?: string;
+ suggestions: TopicSuggestion[];
+}
+
export interface SessionErrorEvent {
kind: 'session_error';
conversationId: string;
@@ -100,6 +117,7 @@ export type WsEvent =
| TtsAudioReceivedEvent
| ConversationEndedEvent
| MemoirUpdatedEvent
+ | TopicSuggestionsEvent
| SessionErrorEvent;
// ─── Connection state ───
diff --git a/app-expo/src/features/conversation/conversation-ws-background-pool.ts b/app-expo/src/features/conversation/conversation-ws-background-pool.ts
index 928dcec..9d56927 100644
--- a/app-expo/src/features/conversation/conversation-ws-background-pool.ts
+++ b/app-expo/src/features/conversation/conversation-ws-background-pool.ts
@@ -32,6 +32,7 @@ function disposeSlot(): void {
const offScreenUi = {
onStreamingText: () => {},
onTtsSegment: () => {},
+ onTopicSuggestions: () => {},
onError: () => {},
onStateChange: () => {},
};
@@ -44,6 +45,7 @@ export function releaseConversationWsUi(
session.releaseUiCallbacks(owner, {
onStreamingText: offScreenUi.onStreamingText,
onTtsSegment: offScreenUi.onTtsSegment,
+ onTopicSuggestions: offScreenUi.onTopicSuggestions,
onError: offScreenUi.onError,
onStateChange: offScreenUi.onStateChange,
});
diff --git a/app-expo/src/features/conversation/hooks.ts b/app-expo/src/features/conversation/hooks.ts
index 9fbb416..92a2738 100644
--- a/app-expo/src/features/conversation/hooks.ts
+++ b/app-expo/src/features/conversation/hooks.ts
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import i18n from '@/i18n';
-import type { WsConnectionState } from '@/core/ws/types';
+import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types';
import { conversationApi } from './api';
import {
@@ -196,12 +196,11 @@ interface RealtimeSessionState {
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
awaitingAssistantReply: boolean;
error: string | null;
- sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void;
- sendVoiceMessage: (
- uri: string,
- durationMs: number,
- options?: { ttsThisTurn?: boolean },
- ) => Promise;
+ /** 服务端下发的 quick-start 话题 chips;用户首次发文本/语音后清空 */
+ topicSuggestions: TopicSuggestion[];
+ dismissTopicSuggestions: () => void;
+ sendText: (text: string) => void;
+ sendVoiceMessage: (uri: string, durationMs: number) => Promise;
sendEndConversation: () => void;
sendTtsCancel: () => void;
requestAssistantSegmentTts: (body: {
@@ -237,6 +236,9 @@ export function useRealtimeSession({
useState(null);
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
const [error, setError] = useState(null);
+ const [topicSuggestions, setTopicSuggestions] = useState(
+ [],
+ );
const [foregroundResumeGeneration, setForegroundResumeGeneration] =
useState(0);
@@ -270,29 +272,16 @@ export function useRealtimeSession({
setError(message);
}, []);
- uiRef.current.handleStreamingText = handleStreamingText;
- uiRef.current.handleError = handleError;
- uiRef.current.onTtsSegment = onTtsSegment;
+ const handleTopicSuggestions = useCallback(
+ (payload: { suggestions: TopicSuggestion[] }) => {
+ setTopicSuggestions(payload.suggestions);
+ },
+ [],
+ );
- useEffect(() => {
- if (!enabled || !conversationId) return;
-
- const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
- if (next === 'background') {
- needsResumeAfterBackgroundRef.current = true;
- disposeAllBackgroundConversationWs();
- sessionRef.current = null;
- setConnectionState('disconnected');
- setStreamingMessage(null);
- setAwaitingAssistantReply(false);
- } else if (next === 'active' && needsResumeAfterBackgroundRef.current) {
- needsResumeAfterBackgroundRef.current = false;
- setForegroundResumeGeneration((g) => g + 1);
- }
- });
-
- return () => sub.remove();
- }, [enabled, conversationId]);
+ const dismissTopicSuggestions = useCallback(() => {
+ setTopicSuggestions([]);
+ }, []);
useEffect(() => {
if (!enabled || !conversationId) return;
@@ -310,6 +299,8 @@ export function useRealtimeSession({
uiRef.current.handleStreamingText(text, isComplete);
},
onTtsSegment: (payload) => uiRef.current.onTtsSegment?.(payload),
+ onTopicSuggestions: (payload) =>
+ handleTopicSuggestions({ suggestions: payload.suggestions }),
onError: (message, code) => uiRef.current.handleError(message, code),
onStateChange: setConnectionState,
},
@@ -325,8 +316,17 @@ export function useRealtimeSession({
setConnectionState('disconnected');
setStreamingMessage(null);
setAwaitingAssistantReply(false);
+ setTopicSuggestions([]);
};
- }, [conversationId, enabled, queryClient, foregroundResumeGeneration]);
+ }, [
+ conversationId,
+ enabled,
+ queryClient,
+ handleStreamingText,
+ handleError,
+ handleTopicSuggestions,
+ onTtsSegment,
+ ]);
const sendText = useCallback(
(text: string, options?: { ttsThisTurn?: boolean }) => {
@@ -341,6 +341,7 @@ export function useRealtimeSession({
onUserSendTtsPreference?.(options?.ttsThisTurn === true);
setAwaitingAssistantReply(true);
+ setTopicSuggestions([]);
onTtsPlaybackResume?.();
const localId = `pending_${Date.now()}`;
@@ -407,6 +408,7 @@ export function useRealtimeSession({
onUserSendTtsPreference?.(options?.ttsThisTurn === true);
setAwaitingAssistantReply(true);
+ setTopicSuggestions([]);
const localId = `pending_voice_${Date.now()}`;
await voiceSegmentStore.recordSentSegment({
voiceSessionId,
@@ -475,6 +477,8 @@ export function useRealtimeSession({
streamingMessage,
awaitingAssistantReply,
error,
+ topicSuggestions,
+ dismissTopicSuggestions,
sendText,
sendVoiceMessage,
sendEndConversation,
diff --git a/app-expo/src/features/conversation/realtime-session.ts b/app-expo/src/features/conversation/realtime-session.ts
index 7a5ae08..6ddcab1 100644
--- a/app-expo/src/features/conversation/realtime-session.ts
+++ b/app-expo/src/features/conversation/realtime-session.ts
@@ -5,7 +5,11 @@ import {
type WsEventListener,
type WsStateListener,
} from '@/core/ws/client';
-import type { WsConnectionState, WsEvent } from '@/core/ws/types';
+import type {
+ TopicSuggestion,
+ WsConnectionState,
+ WsEvent,
+} from '@/core/ws/types';
import { handleWsEvent } from './event-handlers';
import { assistantSegmentMessageId, lastSegmentPreview } from './message-split';
@@ -22,6 +26,11 @@ function looksLikeUuidAssistantMessageId(id: string): boolean {
export type StreamingTextCallback = (text: string, isComplete: boolean) => void;
export type ErrorCallback = (message: string, code?: string) => void;
export type RealtimeSessionUiOwner = symbol;
+export type TopicSuggestionsCallback = (payload: {
+ reason: string;
+ stage?: string;
+ suggestions: TopicSuggestion[];
+}) => void;
/** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */
export type TtsSegmentPayload = {
@@ -43,6 +52,8 @@ interface RealtimeSessionOptions {
onStreamingText?: StreamingTextCallback;
/** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */
onTtsSegment?: (payload: TtsSegmentPayload) => void;
+ /** 服务端下发 quick-start 话题 chips 时回调;用户点击后调 sendText */
+ onTopicSuggestions?: TopicSuggestionsCallback;
onError?: ErrorCallback;
onStateChange?: WsStateListener;
}
@@ -64,6 +75,7 @@ export class RealtimeSession {
private queryClient: QueryClient;
private onStreamingText?: StreamingTextCallback;
private onTtsSegment?: (payload: TtsSegmentPayload) => void;
+ private onTopicSuggestions?: TopicSuggestionsCallback;
private onError?: ErrorCallback;
private uiStateListener?: WsStateListener;
private uiOwner: RealtimeSessionUiOwner | null = null;
@@ -104,6 +116,7 @@ export class RealtimeSession {
this.queryClient = options.queryClient;
this.onStreamingText = options.onStreamingText;
this.onTtsSegment = options.onTtsSegment;
+ this.onTopicSuggestions = options.onTopicSuggestions;
this.onError = options.onError;
this.uiStateListener = options.onStateChange;
@@ -119,6 +132,7 @@ export class RealtimeSession {
options: {
onStreamingText?: StreamingTextCallback;
onTtsSegment?: (payload: TtsSegmentPayload) => void;
+ onTopicSuggestions?: TopicSuggestionsCallback;
onError?: ErrorCallback;
onStateChange?: WsStateListener;
},
@@ -135,6 +149,9 @@ export class RealtimeSession {
if (options.onTtsSegment !== undefined) {
this.onTtsSegment = options.onTtsSegment;
}
+ if (options.onTopicSuggestions !== undefined) {
+ this.onTopicSuggestions = options.onTopicSuggestions;
+ }
if (options.onError !== undefined) {
this.onError = options.onError;
}
@@ -152,6 +169,7 @@ export class RealtimeSession {
options: {
onStreamingText?: StreamingTextCallback;
onTtsSegment?: (payload: TtsSegmentPayload) => void;
+ onTopicSuggestions?: TopicSuggestionsCallback;
onError?: ErrorCallback;
onStateChange?: WsStateListener;
},
@@ -375,6 +393,15 @@ export class RealtimeSession {
return;
}
+ if (event.kind === 'topic_suggestions') {
+ this.onTopicSuggestions?.({
+ reason: event.reason,
+ stage: event.stage,
+ suggestions: event.suggestions,
+ });
+ return;
+ }
+
handleWsEvent(this.queryClient, event);
if (event.kind === 'session_error') {
diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts
index bf45e7d..337753a 100644
--- a/app-expo/src/i18n/generated/resources.ts
+++ b/app-expo/src/i18n/generated/resources.ts
@@ -106,6 +106,7 @@ interface Resources {
tapToStartRecording: 'Tap to start recording';
ttsThisTurn: 'Speak';
ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.';
+ topicSuggestionsDismiss: 'Hide';
timeDaysAgo_one: '{{count}} day ago';
timeDaysAgo_other: '{{count}} days ago';
timeHoursAgo_one: '{{count}} hour ago';
diff --git a/app-expo/src/i18n/locales/en/conversation.json b/app-expo/src/i18n/locales/en/conversation.json
index 4c7f903..7586f10 100644
--- a/app-expo/src/i18n/locales/en/conversation.json
+++ b/app-expo/src/i18n/locales/en/conversation.json
@@ -44,6 +44,7 @@
"switchToVoice": "Switch to voice input",
"tapToEndRecording": "Tap to end",
"tapToStartRecording": "Tap to start recording",
+ "topicSuggestionsDismiss": "Hide",
"viewAll": "View All",
"voiceMessagePreview": "Voice message",
"timeJustNow": "Just now",
diff --git a/app-expo/src/i18n/locales/zh/conversation.json b/app-expo/src/i18n/locales/zh/conversation.json
index e616dd9..93720fc 100644
--- a/app-expo/src/i18n/locales/zh/conversation.json
+++ b/app-expo/src/i18n/locales/zh/conversation.json
@@ -44,6 +44,7 @@
"switchToVoice": "切换到语音输入",
"tapToEndRecording": "点击结束",
"tapToStartRecording": "点击开始录音",
+ "topicSuggestionsDismiss": "收起",
"viewAll": "查看全部",
"voiceMessagePreview": "语音消息",
"timeJustNow": "刚刚",