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": "刚刚",