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:
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ───
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"switchToVoice": "切换到语音输入",
|
||||
"tapToEndRecording": "点击结束",
|
||||
"tapToStartRecording": "点击开始录音",
|
||||
"topicSuggestionsDismiss": "收起",
|
||||
"viewAll": "查看全部",
|
||||
"voiceMessagePreview": "语音消息",
|
||||
"timeJustNow": "刚刚",
|
||||
|
||||
Reference in New Issue
Block a user