feat: agent proactively re-engages users on returning sessions

Two complementary changes to reduce conversation cold-start friction:

A. Returning-user re-greeting (backend)
- When WS reconnects to a non-empty conversation and last_message_at is older
  than chat_re_greeting_idle_hours (default 6h), the agent emits a warm
  continuation message that references prior history instead of staying silent.
- Self-debouncing: the AI message updates last_message_at, so reconnects
  within the window will not re-trigger.
- Skipped while profile collection is still pending.

D. Topic suggestion chips (backend + Expo)
- New WS message type topic_suggestions carries 3-4 quick-start chips derived
  from the current memoir stage's empty slots (deterministic, no extra LLM
  cost). Sent alongside opening / re-greeting / resume.
- Expo chat screen renders a horizontally-scrollable chip row above the input
  bar; tapping a chip sends the chip's text as a user message and clears the
  row. Sending any text/voice also clears the chips.
This commit is contained in:
Claude
2026-05-07 15:39:33 +00:00
parent 7617ea902c
commit 55cfbc7f80
14 changed files with 688 additions and 52 deletions

View File

@@ -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 ["上次聊到的事我还记着,今天想继续往下讲讲吗?"]

View File

@@ -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,
)

View File

@@ -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",
]

View File

@@ -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

View File

@@ -21,4 +21,5 @@ class MessageType(str, Enum):
PONG = "pong"
END_CONVERSATION = "end_conversation"
MEMOIR_UPDATE = "memoir_update"
TOPIC_SUGGESTIONS = "topic_suggestions"
ERROR = "error"

View File

@@ -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
# 资料未齐时不送 chipsprofile 收集走另一条流程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:

View File

@@ -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 (
<View style={styles.topicChipsRow}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.topicChipsScrollContent}
keyboardShouldPersistTaps="handled"
>
{chips.map((chip) => (
<Pressable
key={chip.id}
onPress={() => onPressChip(chip.text)}
style={({ pressed }) => [
styles.topicChip,
pressed && styles.topicChipPressed,
]}
accessibilityRole="button"
accessibilityLabel={chip.label}
>
<Text style={styles.topicChipText} numberOfLines={1}>
{chip.label}
</Text>
</Pressable>
))}
<Pressable
onPress={onDismiss}
style={({ pressed }) => [
styles.topicChipDismiss,
pressed && styles.topicChipPressed,
]}
accessibilityRole="button"
accessibilityLabel={dismissLabel}
>
<Text style={styles.topicChipDismissText}>{dismissLabel}</Text>
</Pressable>
</ScrollView>
</View>
);
}
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() {
</Text>
</View>
) : null}
<TopicChipsRow
chips={topicSuggestions}
onPressChip={handleTopicChipPress}
onDismiss={dismissTopicSuggestions}
dismissLabel={t('topicSuggestionsDismiss')}
/>
<ChatInputBar
value={input}
onChangeText={setInput}
@@ -1826,6 +1920,40 @@ const styles = StyleSheet.create({
paddingHorizontal: 14,
paddingVertical: 12,
},
topicChipsRow: {
paddingTop: 10,
paddingBottom: 4,
},
topicChipsScrollContent: {
paddingHorizontal: 14,
gap: 8,
flexDirection: 'row',
alignItems: 'center',
},
topicChip: {
paddingHorizontal: 14,
paddingVertical: 8,
borderRadius: 18,
backgroundColor: 'rgba(141, 140, 144, 0.14)',
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(141, 140, 144, 0.24)',
},
topicChipPressed: {
opacity: 0.6,
},
topicChipText: {
fontSize: 14,
color: CHAT_COLORS.onSurface,
},
topicChipDismiss: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 18,
},
topicChipDismissText: {
fontSize: 13,
color: 'rgba(60, 60, 67, 0.6)',
},
iconButton: {
width: 44,
height: 44,

View File

@@ -59,6 +59,28 @@ function mapServerMessage(raw: RawServerMessage): WsEvent | null {
case 'memoir_update':
return { kind: 'memoir_updated', conversationId: cid, data: d };
case 'topic_suggestions': {
const rawSuggestions = Array.isArray(d.suggestions) ? d.suggestions : [];
const suggestions = rawSuggestions
.map((raw) => {
if (!raw || typeof raw !== 'object') return null;
const s = raw as Record<string, unknown>;
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',

View File

@@ -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<string, unknown>;
}
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 ───

View File

@@ -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<boolean>;
sendEndConversation: () => void;
@@ -204,6 +207,9 @@ export function useRealtimeSession({
useState<StreamingAgentMessage | null>(null);
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
const [error, setError] = useState<string | null>(null);
const [topicSuggestions, setTopicSuggestions] = useState<TopicSuggestion[]>(
[],
);
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,

View File

@@ -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') {

View File

@@ -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';

View File

@@ -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",

View File

@@ -37,6 +37,7 @@
"switchToVoice": "切换到语音输入",
"tapToEndRecording": "点击结束",
"tapToStartRecording": "点击开始录音",
"topicSuggestionsDismiss": "收起",
"viewAll": "查看全部",
"voiceMessagePreview": "语音消息",
"timeJustNow": "刚刚",