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:
@@ -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,
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user