Merge commit e95582a: PR #20 proactive chat, topic chips, low-info turn plan

- Merge staging workflow parent and resolve conflicts with English/i18n and WS pool
- Re-greeting: language-aware fallbacks and prompts; router passes user_language
- RealtimeSession: topic suggestion callbacks + TTS sync path preserved

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-12 11:02:58 +08:00
21 changed files with 1047 additions and 97 deletions

View File

@@ -1,3 +1,2 @@
# 仅 API/WS 基址TTS 每轮开关由运行时 WS payload 与服务端 ENABLE_TTS 控制(见 api/.env.example
EXPO_PUBLIC_API_URL=http://1.15.29.57:8000
EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000
EXPO_PUBLIC_API_URL=http://1.15.29.57:8000/
EXPO_PUBLIC_WS_URL=ws://1.15.29.57:8000/

View File

@@ -26,6 +26,7 @@ import {
type NativeSyntheticEvent,
Platform,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text as RNText,
@@ -55,6 +56,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 { useSession } from '@/features/auth/hooks';
import { useProfile } from '@/features/profile/hooks';
@@ -868,6 +870,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,
@@ -1377,6 +1431,8 @@ export default function ConversationScreen() {
connectionState,
streamingMessage,
awaitingAssistantReply,
topicSuggestions,
dismissTopicSuggestions,
sendText,
sendVoiceMessage,
sendTtsCancel,
@@ -1648,6 +1704,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';
@@ -1824,6 +1912,12 @@ export default function ConversationScreen() {
</Text>
</View>
) : null}
<TopicChipsRow
chips={topicSuggestions}
onPressChip={handleTopicChipPress}
onDismiss={dismissTopicSuggestions}
dismissLabel={t('topicSuggestionsDismiss')}
/>
<ChatInputBar
value={input}
onChangeText={setInput}
@@ -2133,6 +2227,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

@@ -1,6 +1,14 @@
function trimTrailingSlashes(value: string): string {
return value.replace(/\/+$/, '');
}
export const config = {
apiBaseUrl: process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.10.151:8000',
wsBaseUrl: process.env.EXPO_PUBLIC_WS_URL ?? 'ws://192.168.10.151:8000',
apiBaseUrl: trimTrailingSlashes(
process.env.EXPO_PUBLIC_API_URL ?? 'http://192.168.10.151:8000',
),
wsBaseUrl: trimTrailingSlashes(
process.env.EXPO_PUBLIC_WS_URL ?? 'ws://192.168.10.151:8000',
),
isDebugMode: __DEV__,
api: {

View File

@@ -67,6 +67,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 =
@@ -85,6 +86,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;
@@ -100,6 +117,7 @@ export type WsEvent =
| TtsAudioReceivedEvent
| ConversationEndedEvent
| MemoirUpdatedEvent
| TopicSuggestionsEvent
| SessionErrorEvent;
// ─── Connection state ───

View File

@@ -32,6 +32,7 @@ function disposeSlot(): void {
const offScreenUi = {
onStreamingText: () => {},
onTtsSegment: () => {},
onTopicSuggestions: () => {},
onError: () => {},
onStateChange: () => {},
};
@@ -44,6 +45,7 @@ export function releaseConversationWsUi(
session.releaseUiCallbacks(owner, {
onStreamingText: offScreenUi.onStreamingText,
onTtsSegment: offScreenUi.onTtsSegment,
onTopicSuggestions: offScreenUi.onTopicSuggestions,
onError: offScreenUi.onError,
onStateChange: offScreenUi.onStateChange,
});

View File

@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import i18n from '@/i18n';
import type { WsConnectionState } from '@/core/ws/types';
import type { TopicSuggestion, WsConnectionState } from '@/core/ws/types';
import { conversationApi } from './api';
import {
@@ -196,12 +196,11 @@ interface RealtimeSessionState {
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
awaitingAssistantReply: boolean;
error: string | null;
sendText: (text: string, options?: { ttsThisTurn?: boolean }) => void;
sendVoiceMessage: (
uri: string,
durationMs: number,
options?: { ttsThisTurn?: boolean },
) => Promise<boolean>;
/** 服务端下发的 quick-start 话题 chips用户首次发文本/语音后清空 */
topicSuggestions: TopicSuggestion[];
dismissTopicSuggestions: () => void;
sendText: (text: string) => void;
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
sendEndConversation: () => void;
sendTtsCancel: () => void;
requestAssistantSegmentTts: (body: {
@@ -237,6 +236,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 [foregroundResumeGeneration, setForegroundResumeGeneration] =
useState(0);
@@ -270,29 +272,16 @@ export function useRealtimeSession({
setError(message);
}, []);
uiRef.current.handleStreamingText = handleStreamingText;
uiRef.current.handleError = handleError;
uiRef.current.onTtsSegment = onTtsSegment;
const handleTopicSuggestions = useCallback(
(payload: { suggestions: TopicSuggestion[] }) => {
setTopicSuggestions(payload.suggestions);
},
[],
);
useEffect(() => {
if (!enabled || !conversationId) return;
const sub = AppState.addEventListener('change', (next: AppStateStatus) => {
if (next === 'background') {
needsResumeAfterBackgroundRef.current = true;
disposeAllBackgroundConversationWs();
sessionRef.current = null;
setConnectionState('disconnected');
setStreamingMessage(null);
setAwaitingAssistantReply(false);
} else if (next === 'active' && needsResumeAfterBackgroundRef.current) {
needsResumeAfterBackgroundRef.current = false;
setForegroundResumeGeneration((g) => g + 1);
}
});
return () => sub.remove();
}, [enabled, conversationId]);
const dismissTopicSuggestions = useCallback(() => {
setTopicSuggestions([]);
}, []);
useEffect(() => {
if (!enabled || !conversationId) return;
@@ -310,6 +299,8 @@ export function useRealtimeSession({
uiRef.current.handleStreamingText(text, isComplete);
},
onTtsSegment: (payload) => uiRef.current.onTtsSegment?.(payload),
onTopicSuggestions: (payload) =>
handleTopicSuggestions({ suggestions: payload.suggestions }),
onError: (message, code) => uiRef.current.handleError(message, code),
onStateChange: setConnectionState,
},
@@ -325,8 +316,17 @@ export function useRealtimeSession({
setConnectionState('disconnected');
setStreamingMessage(null);
setAwaitingAssistantReply(false);
setTopicSuggestions([]);
};
}, [conversationId, enabled, queryClient, foregroundResumeGeneration]);
}, [
conversationId,
enabled,
queryClient,
handleStreamingText,
handleError,
handleTopicSuggestions,
onTtsSegment,
]);
const sendText = useCallback(
(text: string, options?: { ttsThisTurn?: boolean }) => {
@@ -341,6 +341,7 @@ export function useRealtimeSession({
onUserSendTtsPreference?.(options?.ttsThisTurn === true);
setAwaitingAssistantReply(true);
setTopicSuggestions([]);
onTtsPlaybackResume?.();
const localId = `pending_${Date.now()}`;
@@ -407,6 +408,7 @@ export function useRealtimeSession({
onUserSendTtsPreference?.(options?.ttsThisTurn === true);
setAwaitingAssistantReply(true);
setTopicSuggestions([]);
const localId = `pending_voice_${Date.now()}`;
await voiceSegmentStore.recordSentSegment({
voiceSessionId,
@@ -475,6 +477,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';
@@ -22,6 +26,11 @@ function looksLikeUuidAssistantMessageId(id: string): boolean {
export type StreamingTextCallback = (text: string, isComplete: boolean) => void;
export type ErrorCallback = (message: string, code?: string) => void;
export type RealtimeSessionUiOwner = symbol;
export type TopicSuggestionsCallback = (payload: {
reason: string;
stage?: string;
suggestions: TopicSuggestion[];
}) => void;
/** WebSocket `tts_audio`:服务端可能只带 base64、只带 COS URL或两者都有 */
export type TtsSegmentPayload = {
@@ -43,6 +52,8 @@ interface RealtimeSessionOptions {
onStreamingText?: StreamingTextCallback;
/** 收到 TTS 片段时入队播放(与「气泡上的手动朗读按钮」无关) */
onTtsSegment?: (payload: TtsSegmentPayload) => void;
/** 服务端下发 quick-start 话题 chips 时回调;用户点击后调 sendText */
onTopicSuggestions?: TopicSuggestionsCallback;
onError?: ErrorCallback;
onStateChange?: WsStateListener;
}
@@ -64,6 +75,7 @@ export class RealtimeSession {
private queryClient: QueryClient;
private onStreamingText?: StreamingTextCallback;
private onTtsSegment?: (payload: TtsSegmentPayload) => void;
private onTopicSuggestions?: TopicSuggestionsCallback;
private onError?: ErrorCallback;
private uiStateListener?: WsStateListener;
private uiOwner: RealtimeSessionUiOwner | null = null;
@@ -104,6 +116,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.uiStateListener = options.onStateChange;
@@ -119,6 +132,7 @@ export class RealtimeSession {
options: {
onStreamingText?: StreamingTextCallback;
onTtsSegment?: (payload: TtsSegmentPayload) => void;
onTopicSuggestions?: TopicSuggestionsCallback;
onError?: ErrorCallback;
onStateChange?: WsStateListener;
},
@@ -135,6 +149,9 @@ export class RealtimeSession {
if (options.onTtsSegment !== undefined) {
this.onTtsSegment = options.onTtsSegment;
}
if (options.onTopicSuggestions !== undefined) {
this.onTopicSuggestions = options.onTopicSuggestions;
}
if (options.onError !== undefined) {
this.onError = options.onError;
}
@@ -152,6 +169,7 @@ export class RealtimeSession {
options: {
onStreamingText?: StreamingTextCallback;
onTtsSegment?: (payload: TtsSegmentPayload) => void;
onTopicSuggestions?: TopicSuggestionsCallback;
onError?: ErrorCallback;
onStateChange?: WsStateListener;
},
@@ -375,6 +393,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

@@ -106,6 +106,7 @@ interface Resources {
tapToStartRecording: 'Tap to start recording';
ttsThisTurn: 'Speak';
ttsThisTurnAccessibility: 'When on, assistant replies synthesize speech before text appears.';
topicSuggestionsDismiss: 'Hide';
timeDaysAgo_one: '{{count}} day ago';
timeDaysAgo_other: '{{count}} days ago';
timeHoursAgo_one: '{{count}} hour ago';

View File

@@ -44,6 +44,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

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