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

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