fix/various fixes

This commit is contained in:
Kevin
2026-03-20 15:15:35 +08:00
parent 7f57f96c25
commit 7317bf10cd
112 changed files with 3790 additions and 2242 deletions

View File

@@ -10,6 +10,10 @@ import {
X,
} from 'lucide-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type {
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
} from 'react-native';
import {
Alert,
Animated,
@@ -57,6 +61,10 @@ const CHAT_COLORS = {
/** 后端用 [SPLIT] 分隔多条消息,需按块渲染为多个气泡 */
const SPLIT_DELIMITER = '[SPLIT]';
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
const CHAT_INPUT_LINE_H = 22;
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
function splitContent(content: string): string[] {
return content
.split(SPLIT_DELIMITER)
@@ -424,6 +432,7 @@ function ChatInputBar({
tapToEndLabel,
cancelRecordingLabel,
disabled,
textInputKey = 0,
}: {
value: string;
onChangeText: (v: string) => void;
@@ -446,8 +455,29 @@ function ChatInputBar({
tapToEndLabel: string;
cancelRecordingLabel: string;
disabled?: boolean;
/** 发送后递增,强制重建 TextInput避免多行高度卡在 4 行 */
textInputKey?: number;
}) {
const colors = useThemeColors();
const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H);
/** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */
const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight;
useEffect(() => {
if (value === '') {
setTextHeight(CHAT_INPUT_LINE_H);
}
}, [value]);
const onInputContentSizeChange = useCallback(
(e: NativeSyntheticEvent<TextInputContentSizeChangeEventData>) => {
const h = e.nativeEvent.contentSize.height;
const next = Math.min(Math.max(h, CHAT_INPUT_LINE_H), CHAT_INPUT_MAX_H);
setTextHeight(next);
},
[],
);
const hasText = value.trim().length > 0;
const showSend = inputMode === 'text' && hasText;
@@ -487,15 +517,18 @@ function ChatInputBar({
{inputMode === 'text' ? (
<View style={styles.inputCenter}>
<TextInput
key={`chat-input-${textInputKey}`}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
placeholderTextColor={CHAT_COLORS.onSurfaceVariant}
style={styles.textInput}
style={[styles.textInput, { height: inputDisplayHeight }]}
multiline
scrollEnabled
textAlignVertical="top"
maxLength={2000}
editable={!disabled}
onContentSizeChange={onInputContentSizeChange}
onSubmitEditing={onSend}
returnKeyType="send"
/>
@@ -600,6 +633,7 @@ export default function ConversationScreen() {
} = useRecorder(handleRecordingComplete);
const [input, setInput] = useState('');
const [inputResetKey, setInputResetKey] = useState(0);
const [inputMode, setInputMode] = useState<InputMode>('text');
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
@@ -609,6 +643,30 @@ export default function ConversationScreen() {
const onShow = (e: { endCoordinates: { height: number } }) => {
setIsKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height);
// #region agent log
void fetch(
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Debug-Session-Id': 'a82a4c',
},
body: JSON.stringify({
sessionId: 'a82a4c',
hypothesisId: 'H3',
location: 'ConversationScreen:keyboardShow',
message: 'keyboard open; composer uses insetBottom 0 when text+kb',
data: {
kbH: e.endCoordinates.height,
insetBottom: insets.bottom,
os: Platform.OS,
},
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
});
@@ -617,13 +675,18 @@ export default function ConversationScreen() {
setIsKeyboardVisible(false);
setKeyboardHeight(0);
};
const subShow = Keyboard.addListener('keyboardDidShow', onShow);
const subHide = Keyboard.addListener('keyboardDidHide', onHide);
// iOSWill* 与系统动画同步KeyboardAvoidingView 在 iOS 上易与 safe area 叠出缝(见 RN #52626
const showEvt =
Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
const hideEvt =
Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
const subShow = Keyboard.addListener(showEvt, onShow);
const subHide = Keyboard.addListener(hideEvt, onHide);
return () => {
subShow.remove();
subHide.remove();
};
}, []);
}, [insets.bottom]);
const flattenedData = flattenMessagesForList(messages ?? []);
@@ -642,6 +705,27 @@ export default function ConversationScreen() {
if (!text) return;
sendText(text);
setInput('');
setInputResetKey((k) => k + 1);
// #region agent log
void fetch(
'http://127.0.0.1:7446/ingest/e6437b8c-57a6-4b5a-9fdd-4a69aa1b3a6c',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Debug-Session-Id': 'a82a4c',
},
body: JSON.stringify({
sessionId: 'a82a4c',
hypothesisId: 'H4',
location: 'ConversationScreen:handleSend',
message: 'cleared input + bumped textInputKey',
data: { sentLen: text.length },
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
};
const connectionLabel =
@@ -651,129 +735,142 @@ export default function ConversationScreen() {
? t('connectionConnecting')
: t('connectionDisconnected');
const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
const kavEnabled = inputMode === 'text' && isKeyboardVisible;
const kavBehavior = Platform.OS === 'ios' ? 'padding' : 'height';
/** iOS用键盘高度直接顶起根布局替代 KAV避免与 safe area 叠出缝,见 RN #52626 */
const keyboardLift =
Platform.OS === 'ios' && inputMode === 'text' && isKeyboardVisible
? keyboardHeight
: 0;
const androidKavOn =
Platform.OS === 'android' && inputMode === 'text' && isKeyboardVisible;
const composerZeroBottomInset = isKeyboardVisible && inputMode === 'text';
const screen = (
<View style={styles.column}>
<ScreenHeader
variant="chat"
title={
<View style={styles.headerTitleBlock}>
<Text style={styles.headerTitle}>{tApp('name')}</Text>
{playerStatus === 'playing' && (
<Icon
as={Volume2}
size={18}
color={CHAT_COLORS.primary}
style={{ marginRight: 6 }}
/>
)}
<View
style={[
styles.statusBadge,
connectionState === 'connected' && styles.statusBadgeConnected,
]}
>
<Text
style={[
styles.statusBadgeText,
connectionState === 'connected' &&
styles.statusBadgeTextConnected,
]}
>
{connectionLabel}
</Text>
</View>
</View>
}
backAccessibilityLabel={t('chatTitle')}
/>
{/* Message list - flex 1, takes remaining space */}
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={styles.listContent}
data={flattenedData}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
keyExtractor={(item) => item.listKey}
renderItem={({ item }) => (
<MessageBubble
item={item}
agentName={t('agentName')}
meLabel={t('me')}
/>
)}
onContentSizeChange={() =>
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
})
}
ListFooterComponent={
streamingMessage ? (
<StreamingBubbles
streamingText={streamingMessage.text}
isComplete={streamingMessage.isComplete}
agentName={t('agentName')}
/>
) : null
}
/>
{/* WeChat-style input bar - 贴在底部,非浮空 */}
<View
style={[
styles.inputBarWrapper,
{
paddingBottom: composerZeroBottomInset ? 0 : insets.bottom,
},
]}
>
<ChatInputBar
value={input}
onChangeText={setInput}
onSend={handleSend}
textInputKey={inputResetKey}
inputMode={inputMode}
onInputModeToggle={() => {
setInputMode((m) => {
if (m === 'text') {
Keyboard.dismiss();
}
return m === 'text' ? 'voice' : 'text';
});
}}
onAddPress={() => {}}
onStartRecording={handleStartRecording}
onStopRecording={() => void stopRecording()}
onCancelRecording={() => void cancelRecording()}
isRecording={isRecording}
recordingDuration={recordingDuration}
placeholder={t('inputPlaceholder')}
placeholderVoice={t('inputPlaceholderVoice')}
addMoreLabel={t('addMore')}
sendLabel={t('send')}
switchToVoiceLabel={t('switchToVoice')}
switchToTextLabel={t('switchToText')}
tapToStartLabel={t('tapToStartRecording')}
tapToEndLabel={t('tapToEndRecording')}
cancelRecordingLabel={t('cancelRecording')}
disabled={connectionState !== 'connected'}
/>
</View>
</View>
);
if (Platform.OS === 'ios') {
return (
<View style={[styles.container, { paddingBottom: keyboardLift }]}>
{screen}
</View>
);
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={kavEnabled ? kavBehavior : undefined}
keyboardVerticalOffset={keyboardOffset}
behavior={androidKavOn ? 'height' : undefined}
>
<View style={styles.column}>
<ScreenHeader
variant="chat"
title={
<View style={styles.headerTitleBlock}>
<Text style={styles.headerTitle}>{tApp('name')}</Text>
{playerStatus === 'playing' && (
<Icon
as={Volume2}
size={18}
color={CHAT_COLORS.primary}
style={{ marginRight: 6 }}
/>
)}
<View
style={[
styles.statusBadge,
connectionState === 'connected' &&
styles.statusBadgeConnected,
]}
>
<Text
style={[
styles.statusBadgeText,
connectionState === 'connected' &&
styles.statusBadgeTextConnected,
]}
>
{connectionLabel}
</Text>
</View>
</View>
}
backAccessibilityLabel={t('chatTitle')}
/>
{/* Message list - flex 1, takes remaining space */}
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={[
styles.listContent,
inputMode === 'text' &&
isKeyboardVisible && { paddingBottom: 12 + keyboardHeight },
]}
data={flattenedData}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
keyExtractor={(item) => item.listKey}
renderItem={({ item }) => (
<MessageBubble
item={item}
agentName={t('agentName')}
meLabel={t('me')}
/>
)}
onContentSizeChange={() =>
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
})
}
ListFooterComponent={
streamingMessage ? (
<StreamingBubbles
streamingText={streamingMessage.text}
isComplete={streamingMessage.isComplete}
agentName={t('agentName')}
/>
) : null
}
/>
{/* WeChat-style input bar - 贴在底部,非浮空 */}
<View
style={[
styles.inputBarWrapper,
{
paddingBottom: insets.bottom,
},
]}
>
<ChatInputBar
value={input}
onChangeText={setInput}
onSend={handleSend}
inputMode={inputMode}
onInputModeToggle={() => {
setInputMode((m) => {
if (m === 'text') {
Keyboard.dismiss();
}
return m === 'text' ? 'voice' : 'text';
});
}}
onAddPress={() => {}}
onStartRecording={handleStartRecording}
onStopRecording={() => void stopRecording()}
onCancelRecording={() => void cancelRecording()}
isRecording={isRecording}
recordingDuration={recordingDuration}
placeholder={t('inputPlaceholder')}
placeholderVoice={t('inputPlaceholderVoice')}
addMoreLabel={t('addMore')}
sendLabel={t('send')}
switchToVoiceLabel={t('switchToVoice')}
switchToTextLabel={t('switchToText')}
tapToStartLabel={t('tapToStartRecording')}
tapToEndLabel={t('tapToEndRecording')}
cancelRecordingLabel={t('cancelRecording')}
disabled={connectionState !== 'connected'}
/>
</View>
</View>
{screen}
</KeyboardAvoidingView>
);
}
@@ -966,7 +1063,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(141, 140, 144, 0.14)',
paddingHorizontal: 16,
paddingVertical: 11,
justifyContent: 'center',
justifyContent: 'flex-start',
},
inputCenterFlex: {
flex: 1,
@@ -1037,11 +1134,11 @@ const styles = StyleSheet.create({
} as const,
textInput: {
fontSize: 16,
lineHeight: 22,
lineHeight: CHAT_INPUT_LINE_H,
color: CHAT_COLORS.onSurface,
padding: 0,
minHeight: 22, // 保持空内容时至少一行高度
maxHeight: 88, // 4 lines * 22 lineHeight
maxHeight: CHAT_INPUT_MAX_H,
width: '100%',
},
sendButton: {
height: 44,