diff --git a/api/app/agents/chat/interview_agent.py b/api/app/agents/chat/interview_agent.py index 83af4ba..03b3679 100644 --- a/api/app/agents/chat/interview_agent.py +++ b/api/app/agents/chat/interview_agent.py @@ -23,6 +23,7 @@ from app.agents.chat.prompt_context import ChatPromptContext from app.agents.chat.prompts_conversation import ( SLOT_NAME_MAP, get_opening_prompt, + get_re_greeting_prompt, ) from app.agents.chat.reply_limits import ( nonempty_segments_or_fallback, @@ -503,3 +504,118 @@ class InterviewAgent: except Exception as e: logger.error("生成开场白失败: {}", e, exc_info=True) return ["你好呀~ 又见面了。今天想从人生里哪一小段回忆开始聊聊?"] + + 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 = "", + ) -> List[str]: + """老对话回访问候:用户带着已有历史回到对话时,AI 主动做承接式开场。 + + 与 generate_opening_message 的差异:prompt 明确告知有历史 + 距上次的时间感受, + 要求轻轻引用历史里的具体细节,不能用首次见面式硬开场。 + """ + if not self.llm: + 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 + ) + empty_slots_readable = [SLOT_NAME_MAP.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, + ) + 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) + messages.append( + HumanMessage( + content=( + "(用户回到这个已有历史的对话,还没说话。" + "请基于上文做温和的承接式回访问候。)" + ) + ) + ) + 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="上次聊到的事我还记着,今天想继续往下讲讲吗?", + ) + except Exception as e: + logger.error("生成回访问候失败: {}", e, exc_info=True) + return ["上次聊到的事我还记着,今天想继续往下讲讲吗?"] diff --git a/api/app/agents/chat/orchestrator.py b/api/app/agents/chat/orchestrator.py index d7f43f4..32c248a 100644 --- a/api/app/agents/chat/orchestrator.py +++ b/api/app/agents/chat/orchestrator.py @@ -477,3 +477,26 @@ class ChatOrchestrator: profile_birth_year=profile_birth_year, profile_era_place=profile_era_place, ) + + 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 = "", + ) -> 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, + ) diff --git a/api/app/agents/chat/prompts_conversation.py b/api/app/agents/chat/prompts_conversation.py index 260d673..ddf8546 100644 --- a/api/app/agents/chat/prompts_conversation.py +++ b/api/app/agents/chat/prompts_conversation.py @@ -305,8 +305,176 @@ def get_guided_conversation_prompt( # 旧的超大 system prompt 已拆入 BehaviorPolicy / Context / StyleProfile 三层,此处不再保留快照。 +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, +) -> str: + """老对话回访问候提示词:用户带着已有历史回到对话,AI 先开口做承接式问候。""" + stage_name = STAGE_DISPLAY_ZH.get(current_stage, current_stage) + 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", + "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 393dda3..8adceda 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 c69a13d..3aafd99 100644 --- a/api/app/features/conversation/ws/message_types.py +++ b/api/app/features/conversation/ws/message_types.py @@ -21,4 +21,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 aa34e1e..ef89f8c 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 @@ -43,6 +49,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, @@ -156,6 +174,87 @@ async def websocket_endpoint( history = await conversation_service.ensure_redis_history_from_db( conversation_id ) + + 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: @@ -165,35 +264,13 @@ async def websocket_endpoint( missing_fields=missing_profile, nickname=user.nickname or "", ) - 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, @@ -204,7 +281,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 @@ -214,32 +291,60 @@ async def websocket_endpoint( profile_era_place=era_place, ) ) - 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, + ) + 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, + ) + ) + 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/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 0faea35..66ae520 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -27,6 +27,7 @@ import { type NativeSyntheticEvent, Platform, Pressable, + ScrollView, StyleSheet, Text as RNText, TextInput, @@ -50,6 +51,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 { assistantSegmentMessageId, @@ -715,6 +717,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, @@ -1171,6 +1225,8 @@ export default function ConversationScreen() { connectionState, streamingMessage, awaitingAssistantReply, + topicSuggestions, + dismissTopicSuggestions, sendText, sendVoiceMessage, sendTtsCancel, @@ -1394,6 +1450,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'; @@ -1533,6 +1621,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 ac32563..905759f 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 = @@ -81,6 +82,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; @@ -96,6 +113,7 @@ export type WsEvent = | TtsAudioReceivedEvent | ConversationEndedEvent | MemoirUpdatedEvent + | TopicSuggestionsEvent | SessionErrorEvent; // ─── Connection state ─── diff --git a/app-expo/src/features/conversation/hooks.ts b/app-expo/src/features/conversation/hooks.ts index 7bf1420..ae44534 100644 --- a/app-expo/src/features/conversation/hooks.ts +++ b/app-expo/src/features/conversation/hooks.ts @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { File, Paths } from 'expo-file-system'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { WsConnectionState } from '@/core/ws/types'; +import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types'; import { conversationApi } from './api'; import { conversationMessagesRepository } from './conversation-messages-repository'; @@ -183,6 +183,9 @@ interface RealtimeSessionState { /** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */ awaitingAssistantReply: boolean; error: string | null; + /** 服务端下发的 quick-start 话题 chips;用户首次发文本/语音后清空 */ + topicSuggestions: TopicSuggestion[]; + dismissTopicSuggestions: () => void; sendText: (text: string) => void; sendVoiceMessage: (uri: string, durationMs: number) => Promise; sendEndConversation: () => void; @@ -204,6 +207,9 @@ export function useRealtimeSession({ useState(null); const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false); const [error, setError] = useState(null); + const [topicSuggestions, setTopicSuggestions] = useState( + [], + ); const handleStreamingText: StreamingTextCallback = useCallback( (text, isComplete) => { @@ -225,6 +231,17 @@ export function useRealtimeSession({ setError(message); }, []); + const handleTopicSuggestions = useCallback( + (payload: { suggestions: TopicSuggestion[] }) => { + setTopicSuggestions(payload.suggestions); + }, + [], + ); + + const dismissTopicSuggestions = useCallback(() => { + setTopicSuggestions([]); + }, []); + useEffect(() => { if (!enabled || !conversationId) return; @@ -233,6 +250,7 @@ export function useRealtimeSession({ queryClient, onStreamingText: handleStreamingText, onTtsSegment, + onTopicSuggestions: handleTopicSuggestions, onError: handleError, onStateChange: setConnectionState, }); @@ -246,6 +264,7 @@ export function useRealtimeSession({ setConnectionState('disconnected'); setStreamingMessage(null); setAwaitingAssistantReply(false); + setTopicSuggestions([]); }; }, [ conversationId, @@ -253,6 +272,7 @@ export function useRealtimeSession({ queryClient, handleStreamingText, handleError, + handleTopicSuggestions, onTtsSegment, ]); @@ -267,6 +287,7 @@ export function useRealtimeSession({ } setAwaitingAssistantReply(true); + setTopicSuggestions([]); onTtsPlaybackResume?.(); const localId = `pending_${Date.now()}`; @@ -326,6 +347,7 @@ export function useRealtimeSession({ } setAwaitingAssistantReply(true); + setTopicSuggestions([]); const localId = `pending_voice_${Date.now()}`; await voiceSegmentStore.recordSentSegment({ voiceSessionId, @@ -385,6 +407,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 ff1effb..63f4629 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'; @@ -14,6 +18,11 @@ import type { ConversationListItem, MessageItem } from './types'; export type StreamingTextCallback = (text: string, isComplete: boolean) => void; export type ErrorCallback = (message: string, code?: string) => void; +export type TopicSuggestionsCallback = (payload: { + reason: string; + stage?: string; + suggestions: TopicSuggestion[]; +}) => void; /** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL,或两者都有 */ export type TtsSegmentPayload = { @@ -31,6 +40,8 @@ interface RealtimeSessionOptions { onStreamingText?: StreamingTextCallback; /** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */ onTtsSegment?: (payload: TtsSegmentPayload) => void; + /** 服务端下发 quick-start 话题 chips 时回调;用户点击后调 sendText */ + onTopicSuggestions?: TopicSuggestionsCallback; onError?: ErrorCallback; onStateChange?: WsStateListener; } @@ -52,6 +63,7 @@ export class RealtimeSession { private queryClient: QueryClient; private onStreamingText?: StreamingTextCallback; private onTtsSegment?: (payload: TtsSegmentPayload) => void; + private onTopicSuggestions?: TopicSuggestionsCallback; private onError?: ErrorCallback; private unsubEvent: (() => void) | null = null; private unsubState: (() => void) | null = null; @@ -66,6 +78,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.unsubEvent = this.client.onEvent(this.handleEvent); @@ -154,6 +167,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 dedbc9e..94ed1a6 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -99,6 +99,7 @@ interface Resources { switchToVoice: 'Switch to voice input'; tapToEndRecording: 'Tap to end'; tapToStartRecording: 'Tap to start recording'; + 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 127f056..a54eeac 100644 --- a/app-expo/src/i18n/locales/en/conversation.json +++ b/app-expo/src/i18n/locales/en/conversation.json @@ -37,6 +37,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 9fffe59..2621fbe 100644 --- a/app-expo/src/i18n/locales/zh/conversation.json +++ b/app-expo/src/i18n/locales/zh/conversation.json @@ -37,6 +37,7 @@ "switchToVoice": "切换到语音输入", "tapToEndRecording": "点击结束", "tapToStartRecording": "点击开始录音", + "topicSuggestionsDismiss": "收起", "viewAll": "查看全部", "voiceMessagePreview": "语音消息", "timeJustNow": "刚刚",