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