feat(memoir+conversation): 章节/故事最小可读字数;会话 hasUserMessage 与 UI 优化
- 后端:300 字门槛统一物化、hydrate、列表/PDF/详情;过短章节对读者隐藏 - 对话:首包前打字动画、大字模式排版、朗读/TTS 交互与布局稳定 - 首页:复用无用户消息会话;空列表「继续对话」与文案 i18n - 章节阅读:标题进正文、封面与去重标题;阅读 Markdown 字号上调
This commit is contained in:
@@ -153,6 +153,27 @@ const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
|
||||
sepia: READING_COLORS.backgroundSepia,
|
||||
};
|
||||
|
||||
/** 章节标题放在正文内(顶栏仅保留返回与操作),与分段阅读一致 */
|
||||
const CHAPTER_TITLE_IN_BODY_STYLE = {
|
||||
fontSize: 26,
|
||||
fontWeight: '700' as const,
|
||||
color: READING_COLORS.primary,
|
||||
lineHeight: 34,
|
||||
letterSpacing: -0.4,
|
||||
};
|
||||
|
||||
function escapeRegExp(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/** 物化正文若已含与章节同名的 Markdown 标题,去掉避免与正文大标题重复 */
|
||||
function stripLeadingChapterHeading(md: string, chapterTitle: string): string {
|
||||
const t = chapterTitle.trim();
|
||||
if (!t || !md.trim()) return md;
|
||||
const re = new RegExp(`^\\s*#{1,6}\\s*${escapeRegExp(t)}\\s*\\n+`, 'u');
|
||||
return md.replace(re, '').trimStart();
|
||||
}
|
||||
|
||||
function ReadingSettingsModal({
|
||||
visible,
|
||||
onClose,
|
||||
@@ -436,7 +457,7 @@ export default function ChapterScreen() {
|
||||
variant="reading"
|
||||
absolute
|
||||
backgroundColor={bgColor}
|
||||
title={useReadingSegments ? '' : chapter.title}
|
||||
title=""
|
||||
backAccessibilityLabel={t('chapterReading.back')}
|
||||
right={
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
|
||||
@@ -502,16 +523,7 @@ export default function ChapterScreen() {
|
||||
paddingBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={{
|
||||
fontSize: 26,
|
||||
fontWeight: '700',
|
||||
color: READING_COLORS.primary,
|
||||
lineHeight: 34,
|
||||
letterSpacing: -0.4,
|
||||
}}
|
||||
>
|
||||
<Text selectable style={CHAPTER_TITLE_IN_BODY_STYLE}>
|
||||
{chapter.title}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -543,15 +555,41 @@ export default function ChapterScreen() {
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<MarkdownRenderer
|
||||
markdown={canonicalMarkdown}
|
||||
renderedAssets={renderedAssets}
|
||||
coverImageUrl={coverImageUrl}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
backgroundColor={bgColor}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
{coverImageUrl ? (
|
||||
<ChapterCoverHero
|
||||
coverImageUrl={coverImageUrl}
|
||||
backgroundColor={bgColor}
|
||||
/>
|
||||
) : null}
|
||||
<View
|
||||
style={{
|
||||
maxWidth: contentWidth,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
paddingHorizontal: 20,
|
||||
marginTop: coverImageUrl ? -28 : 0,
|
||||
paddingTop: coverImageUrl ? 40 : 8,
|
||||
paddingBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Text selectable style={CHAPTER_TITLE_IN_BODY_STYLE}>
|
||||
{chapter.title}
|
||||
</Text>
|
||||
</View>
|
||||
<MarkdownRenderer
|
||||
markdown={stripLeadingChapterHeading(
|
||||
canonicalMarkdown,
|
||||
chapter.title,
|
||||
)}
|
||||
renderedAssets={renderedAssets}
|
||||
coverImageUrl={null}
|
||||
fontSize={fontSize}
|
||||
fontFamily={fontFamily}
|
||||
backgroundColor={bgColor}
|
||||
contentWidth={contentWidth}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
|
||||
@@ -10,12 +10,19 @@ import {
|
||||
Volume2,
|
||||
X,
|
||||
} from 'lucide-react-native';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import type {
|
||||
LayoutChangeEvent,
|
||||
NativeSyntheticEvent,
|
||||
TextInputContentSizeChangeEventData,
|
||||
} from 'react-native';
|
||||
import type { TextStyle } from 'react-native';
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
@@ -37,7 +44,9 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenHeader } from '@/components/screen-header';
|
||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
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 { conversationKeys } from '@/features/conversation/query-keys';
|
||||
@@ -68,10 +77,6 @@ const CHAT_COLORS = {
|
||||
errorRed: '#ba1a1a',
|
||||
};
|
||||
|
||||
/** 聊天输入框:单行内容高度与最多约 4 行(与 lineHeight 对齐) */
|
||||
const CHAT_INPUT_LINE_H = 22;
|
||||
const CHAT_INPUT_MAX_H = CHAT_INPUT_LINE_H * 4;
|
||||
|
||||
/** 与 archive/app-ios-react-app 中 `app-android/.../drawable/avatar_assistant.png` 同源(岁月知己) */
|
||||
const AGENT_AVATAR = require('@/assets/images/avatar-assistant.png');
|
||||
const USER_AVATAR =
|
||||
@@ -128,6 +133,10 @@ function MessageBubble({
|
||||
onPausePlayback,
|
||||
onInterruptAssistantTts,
|
||||
onReplayAssistantTts,
|
||||
bubbleTextStyle,
|
||||
voiceDurationTextStyle,
|
||||
readAloudIconSize,
|
||||
readAloudButtonSize,
|
||||
}: {
|
||||
item: MessageItem;
|
||||
listKey: string;
|
||||
@@ -140,6 +149,10 @@ function MessageBubble({
|
||||
onPausePlayback: () => void;
|
||||
onInterruptAssistantTts: () => void;
|
||||
onReplayAssistantTts: (messageId: string, urls: string[]) => void;
|
||||
bubbleTextStyle?: TextStyle;
|
||||
voiceDurationTextStyle?: TextStyle;
|
||||
readAloudIconSize: number;
|
||||
readAloudButtonSize: number;
|
||||
}) {
|
||||
const { t } = useTranslation('conversation');
|
||||
const isUser = item.senderType === 'user';
|
||||
@@ -171,62 +184,39 @@ function MessageBubble({
|
||||
isAssistantTtsHighlight && styles.bubbleAgentTtsActive,
|
||||
]}
|
||||
>
|
||||
<Text selectable style={[styles.bubbleText, styles.bubbleTextAgent]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.bubbleText, styles.bubbleTextAgent, bubbleTextStyle]}
|
||||
>
|
||||
{item.content}
|
||||
</Text>
|
||||
{isAssistantTtsHighlight ? (
|
||||
<Text style={styles.readingAloudCaption}>{t('readingAloud')}</Text>
|
||||
) : null}
|
||||
{isAssistantTextFirstPart ? (
|
||||
{isAssistantTextFirstPart &&
|
||||
(ttsUrls.length > 0 || isThisBubbleTtsTarget) ? (
|
||||
<View style={styles.readAloudRow}>
|
||||
{isThisBubbleTtsTarget ? (
|
||||
<View
|
||||
style={styles.readAloudButtonInner}
|
||||
accessibilityElementsHidden
|
||||
importantForAccessibility="no-hide-descendants"
|
||||
>
|
||||
<Icon as={Square} size={16} color={CHAT_COLORS.primary} />
|
||||
<Text style={styles.readAloudButtonLabel}>
|
||||
{t('stopReadingAloud')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (ttsUrls.length) {
|
||||
onReplayAssistantTts(item.id, ttsUrls);
|
||||
}
|
||||
}}
|
||||
disabled={!ttsUrls.length}
|
||||
style={({ pressed }) => [
|
||||
styles.readAloudButton,
|
||||
!ttsUrls.length && styles.readAloudButtonDisabled,
|
||||
pressed && ttsUrls.length && { opacity: 0.85 },
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
ttsUrls.length ? t('readAloudAgain') : t('cannotReadAloud')
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
as={Volume2}
|
||||
size={16}
|
||||
color={
|
||||
ttsUrls.length
|
||||
? CHAT_COLORS.primary
|
||||
: CHAT_COLORS.onSurfaceVariant
|
||||
}
|
||||
/>
|
||||
<Text
|
||||
style={[
|
||||
styles.readAloudButtonLabel,
|
||||
!ttsUrls.length && styles.readAloudButtonLabelDisabled,
|
||||
]}
|
||||
>
|
||||
{ttsUrls.length ? t('readAloudAgain') : t('cannotReadAloud')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
<Pressable
|
||||
disabled={isThisBubbleTtsTarget}
|
||||
onPress={() => {
|
||||
onReplayAssistantTts(item.id, ttsUrls);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.readAloudButton,
|
||||
{ width: readAloudButtonSize, height: readAloudButtonSize },
|
||||
!isThisBubbleTtsTarget && pressed ? { opacity: 0.85 } : null,
|
||||
]}
|
||||
accessibilityElementsHidden={isThisBubbleTtsTarget}
|
||||
importantForAccessibility={
|
||||
isThisBubbleTtsTarget ? 'no-hide-descendants' : 'auto'
|
||||
}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('readAloudAgain')}
|
||||
accessibilityState={{ disabled: isThisBubbleTtsTarget }}
|
||||
>
|
||||
<Icon
|
||||
as={isThisBubbleTtsTarget ? Square : Volume2}
|
||||
size={readAloudIconSize}
|
||||
color={CHAT_COLORS.primary}
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -265,6 +255,7 @@ function MessageBubble({
|
||||
playbackIsPlaying &&
|
||||
currentPlaybackUri === item.audioUri
|
||||
}
|
||||
durationTextStyle={voiceDurationTextStyle}
|
||||
onPlayPress={() => {
|
||||
if (!item.audioUri) return;
|
||||
if (playbackIsPlaying && currentPlaybackUri === item.audioUri) {
|
||||
@@ -277,41 +268,179 @@ function MessageBubble({
|
||||
</View>
|
||||
) : isUser ? (
|
||||
<View style={[styles.bubble, styles.bubbleUser]}>
|
||||
<Text selectable style={[styles.bubbleText, styles.bubbleTextUser]}>
|
||||
<Text
|
||||
selectable
|
||||
style={[
|
||||
styles.bubbleText,
|
||||
styles.bubbleTextUser,
|
||||
bubbleTextStyle,
|
||||
]}
|
||||
>
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
) : isThisBubbleTtsTarget ? (
|
||||
<Pressable
|
||||
onPress={onInterruptAssistantTts}
|
||||
style={({ pressed }) => [pressed && { opacity: 0.92 }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('stopReadingAloud')}
|
||||
>
|
||||
{assistantTextBubbleBody}
|
||||
</Pressable>
|
||||
) : (
|
||||
assistantTextBubbleBody
|
||||
<View style={styles.assistantBubbleWrap}>
|
||||
{assistantTextBubbleBody}
|
||||
{isThisBubbleTtsTarget ? (
|
||||
<Pressable
|
||||
onPress={onInterruptAssistantTts}
|
||||
style={({ pressed }) => [
|
||||
StyleSheet.absoluteFill,
|
||||
pressed && styles.assistantTtsInterruptOverlayPressed,
|
||||
]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('stopReadingAloud')}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function TypingDot({ delay }: { delay: number }) {
|
||||
const y = useRef(new Animated.Value(0)).current;
|
||||
useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.delay(delay),
|
||||
Animated.timing(y, {
|
||||
toValue: 1,
|
||||
duration: 380,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(y, {
|
||||
toValue: 0,
|
||||
duration: 380,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [delay, y]);
|
||||
const translateY = y.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [0, -6],
|
||||
});
|
||||
return (
|
||||
<Animated.View
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: 4,
|
||||
backgroundColor: CHAT_COLORS.primary,
|
||||
opacity: 0.88,
|
||||
transform: [{ translateY }],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 首包到达前:气泡内三点跳动,表示 AI 正在回复 */
|
||||
function AssistantTypingBubble({
|
||||
agentName,
|
||||
labelStyle,
|
||||
}: {
|
||||
agentName: string;
|
||||
labelStyle?: TextStyle;
|
||||
}) {
|
||||
const { t } = useTranslation('conversation');
|
||||
const bubblePulse = useRef(new Animated.Value(0.92)).current;
|
||||
useEffect(() => {
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(bubblePulse, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(bubblePulse, {
|
||||
toValue: 0.92,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [bubblePulse]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.messageRow, styles.streamingRow]}
|
||||
accessibilityRole="text"
|
||||
accessibilityLabel={t('assistantReplying')}
|
||||
>
|
||||
<View style={[styles.avatarWrapper, styles.avatarWrapperAgent]}>
|
||||
<Image
|
||||
source={AGENT_AVATAR}
|
||||
style={styles.avatarImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory-disk"
|
||||
alt={agentName}
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.bubbleColumn]}>
|
||||
<Animated.View
|
||||
style={[styles.bubble, styles.bubbleAgent, { opacity: bubblePulse }]}
|
||||
>
|
||||
<View style={styles.typingDotsRow}>
|
||||
<TypingDot delay={0} />
|
||||
<TypingDot delay={110} />
|
||||
<TypingDot delay={220} />
|
||||
</View>
|
||||
<Text style={[styles.typingHintText, labelStyle]}>
|
||||
{t('assistantReplying')}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingBubbles({
|
||||
streamingText,
|
||||
isComplete,
|
||||
agentName,
|
||||
streamingTtsActive,
|
||||
onStreamingPress,
|
||||
bubbleTextStyle,
|
||||
}: {
|
||||
streamingText: string;
|
||||
isComplete: boolean;
|
||||
agentName: string;
|
||||
streamingTtsActive?: boolean;
|
||||
onStreamingPress?: () => void;
|
||||
bubbleTextStyle?: TextStyle;
|
||||
}) {
|
||||
const { t } = useTranslation('conversation');
|
||||
const streamPulse = useRef(new Animated.Value(1)).current;
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
streamPulse.setValue(1);
|
||||
return;
|
||||
}
|
||||
const loop = Animated.loop(
|
||||
Animated.sequence([
|
||||
Animated.timing(streamPulse, {
|
||||
toValue: 0.82,
|
||||
duration: 650,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(streamPulse, {
|
||||
toValue: 1,
|
||||
duration: 650,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
loop.start();
|
||||
return () => loop.stop();
|
||||
}, [isComplete, streamPulse]);
|
||||
|
||||
const segments = splitStreamingSegments(streamingText);
|
||||
const completedParts =
|
||||
segments.length > 1
|
||||
@@ -346,7 +475,11 @@ function StreamingBubbles({
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.bubbleText, styles.bubbleTextAgent]}
|
||||
style={[
|
||||
styles.bubbleText,
|
||||
styles.bubbleTextAgent,
|
||||
bubbleTextStyle,
|
||||
]}
|
||||
>
|
||||
{part}
|
||||
</Text>
|
||||
@@ -365,28 +498,48 @@ function StreamingBubbles({
|
||||
/>
|
||||
</View>
|
||||
<View style={[styles.bubbleColumn]}>
|
||||
<View
|
||||
style={[
|
||||
styles.bubble,
|
||||
styles.bubbleAgent,
|
||||
streamingTtsActive && styles.bubbleAgentTtsActive,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={[styles.bubbleText, styles.bubbleTextAgent]}
|
||||
{!isComplete ? (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.bubble,
|
||||
styles.bubbleAgent,
|
||||
streamingTtsActive && styles.bubbleAgentTtsActive,
|
||||
{ opacity: streamPulse },
|
||||
]}
|
||||
>
|
||||
{streamingPart}
|
||||
{!isComplete && '▌'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
selectable
|
||||
style={[
|
||||
styles.bubbleText,
|
||||
styles.bubbleTextAgent,
|
||||
bubbleTextStyle,
|
||||
]}
|
||||
>
|
||||
{streamingPart}▌
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<View
|
||||
style={[
|
||||
styles.bubble,
|
||||
styles.bubbleAgent,
|
||||
streamingTtsActive && styles.bubbleAgentTtsActive,
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
selectable
|
||||
style={[
|
||||
styles.bubbleText,
|
||||
styles.bubbleTextAgent,
|
||||
bubbleTextStyle,
|
||||
]}
|
||||
>
|
||||
{streamingPart}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{streamingTtsActive ? (
|
||||
<View style={styles.streamingTtsCaptionRow}>
|
||||
<Text style={styles.readingAloudCaption}>{t('readingAloud')}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -418,12 +571,14 @@ function VoiceMessageBubble({
|
||||
isUser,
|
||||
isPlaying,
|
||||
onPlayPress,
|
||||
durationTextStyle,
|
||||
}: {
|
||||
durationSeconds: number;
|
||||
audioUri?: string;
|
||||
isUser: boolean;
|
||||
isPlaying: boolean;
|
||||
onPlayPress: () => void;
|
||||
durationTextStyle?: TextStyle;
|
||||
}) {
|
||||
const handlePlayPause = useCallback(() => {
|
||||
onPlayPress();
|
||||
@@ -457,6 +612,7 @@ function VoiceMessageBubble({
|
||||
style={[
|
||||
styles.voiceDurationText,
|
||||
isUser ? styles.voiceDurationTextUser : styles.voiceDurationTextAgent,
|
||||
durationTextStyle,
|
||||
]}
|
||||
>
|
||||
{formatRecordingDuration(durationSeconds)}
|
||||
@@ -472,6 +628,8 @@ function VoiceRecordButton({
|
||||
tapToStartLabel,
|
||||
tapToEndLabel,
|
||||
enabled,
|
||||
labelStyle,
|
||||
durationStyle,
|
||||
}: {
|
||||
isRecording: boolean;
|
||||
recordingDuration: number;
|
||||
@@ -479,6 +637,8 @@ function VoiceRecordButton({
|
||||
tapToStartLabel: string;
|
||||
tapToEndLabel: string;
|
||||
enabled: boolean;
|
||||
labelStyle?: TextStyle;
|
||||
durationStyle?: TextStyle;
|
||||
}) {
|
||||
const pulseAnim = useRef(new Animated.Value(0.85)).current;
|
||||
|
||||
@@ -518,6 +678,7 @@ function VoiceRecordButton({
|
||||
styles.voiceRecordLabel,
|
||||
styles.voiceRecordLabelCenter,
|
||||
styles.voiceRecordLabelRecording,
|
||||
labelStyle,
|
||||
!enabled && styles.voiceRecordLabelDisabled,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
@@ -531,6 +692,7 @@ function VoiceRecordButton({
|
||||
styles.voiceRecordLabel,
|
||||
!enabled && styles.voiceRecordLabelDisabled,
|
||||
styles.voiceRecordLabelCenter,
|
||||
labelStyle,
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
@@ -549,6 +711,7 @@ function VoiceRecordButton({
|
||||
<RNText
|
||||
style={[
|
||||
styles.voiceRecordDuration,
|
||||
durationStyle,
|
||||
Platform.OS === 'android' && styles.voiceRecordDurationAndroid,
|
||||
]}
|
||||
{...(Platform.OS === 'android' && { includeFontPadding: false })}
|
||||
@@ -586,6 +749,11 @@ function ChatInputBar({
|
||||
disabled,
|
||||
textInputKey = 0,
|
||||
onInputDisplayHeightChange,
|
||||
inputLineHeight,
|
||||
textInputStyle,
|
||||
sendButtonLabelStyle,
|
||||
voiceRecordLabelStyle,
|
||||
voiceRecordDurationStyle,
|
||||
}: {
|
||||
value: string;
|
||||
onChangeText: (v: string) => void;
|
||||
@@ -612,17 +780,24 @@ function ChatInputBar({
|
||||
textInputKey?: number;
|
||||
/** 文字输入框实际绘制高度变化(单行/多行),供列表滚到底 */
|
||||
onInputDisplayHeightChange?: (height: number) => void;
|
||||
/** 与全局 typography 一致的单行行高、最多约 4 行 */
|
||||
inputLineHeight: number;
|
||||
textInputStyle?: TextStyle;
|
||||
sendButtonLabelStyle?: TextStyle;
|
||||
voiceRecordLabelStyle?: TextStyle;
|
||||
voiceRecordDurationStyle?: TextStyle;
|
||||
}) {
|
||||
const colors = useThemeColors();
|
||||
const [textHeight, setTextHeight] = useState(CHAT_INPUT_LINE_H);
|
||||
const inputMaxH = inputLineHeight * 4;
|
||||
const [textHeight, setTextHeight] = useState(inputLineHeight);
|
||||
/** 空串时立即单行高度,避免仅依赖 state 时发送后仍沿用 4 行测量值 */
|
||||
const inputDisplayHeight = value === '' ? CHAT_INPUT_LINE_H : textHeight;
|
||||
const inputDisplayHeight = value === '' ? inputLineHeight : textHeight;
|
||||
|
||||
useEffect(() => {
|
||||
if (value === '') {
|
||||
setTextHeight(CHAT_INPUT_LINE_H);
|
||||
setTextHeight(inputLineHeight);
|
||||
}
|
||||
}, [value]);
|
||||
}, [value, inputLineHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputMode !== 'text' || !onInputDisplayHeightChange) return;
|
||||
@@ -632,10 +807,10 @@ function ChatInputBar({
|
||||
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);
|
||||
const next = Math.min(Math.max(h, inputLineHeight), inputMaxH);
|
||||
setTextHeight(next);
|
||||
},
|
||||
[],
|
||||
[inputLineHeight, inputMaxH],
|
||||
);
|
||||
|
||||
const hasText = value.trim().length > 0;
|
||||
@@ -677,7 +852,11 @@ function ChatInputBar({
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={CHAT_COLORS.onSurfaceVariant}
|
||||
style={[styles.textInput, { height: inputDisplayHeight }]}
|
||||
style={[
|
||||
styles.textInput,
|
||||
textInputStyle,
|
||||
{ height: inputDisplayHeight },
|
||||
]}
|
||||
multiline
|
||||
scrollEnabled
|
||||
textAlignVertical="top"
|
||||
@@ -697,6 +876,8 @@ function ChatInputBar({
|
||||
tapToStartLabel={tapToStartLabel}
|
||||
tapToEndLabel={tapToEndLabel}
|
||||
enabled={!disabled}
|
||||
labelStyle={voiceRecordLabelStyle}
|
||||
durationStyle={voiceRecordDurationStyle}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -715,7 +896,11 @@ function ChatInputBar({
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text
|
||||
style={[styles.sendButtonText, { color: CHAT_COLORS.onSurface }]}
|
||||
style={[
|
||||
styles.sendButtonText,
|
||||
sendButtonLabelStyle,
|
||||
{ color: CHAT_COLORS.onSurface },
|
||||
]}
|
||||
>
|
||||
{sendLabel}
|
||||
</Text>
|
||||
@@ -764,6 +949,102 @@ export default function ConversationScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { t } = useTranslation('conversation');
|
||||
const { t: tApp } = useTranslation('app');
|
||||
const typography = useTypography();
|
||||
const { largeText } = useAppSettings();
|
||||
|
||||
/** 大字模式:对话气泡与输入使用更大一档,与设置中的「大字」一致 */
|
||||
const chatBubbleTextStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingMedium : typography.bodyLarge,
|
||||
lineHeight: largeText
|
||||
? typography.lineHeightXLoose
|
||||
: typography.lineHeightLoose,
|
||||
fontWeight: '400' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
/** 大字模式:朗读图标与触控区与气泡字号同档放大 */
|
||||
const chatReadAloudIconSize = largeText ? 24 : 20;
|
||||
const chatReadAloudButtonSize = largeText ? 52 : 44;
|
||||
const chatVoiceDurationStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingSmall : typography.titleMedium,
|
||||
fontWeight: '500' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const chatTypingLabelStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
|
||||
lineHeight: typography.lineHeightNormal,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const headerTitleFontStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingMedium : typography.headingSmall,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const inputLineHeight = largeText
|
||||
? typography.lineHeightLoose
|
||||
: typography.lineHeightNormal;
|
||||
const inputTextStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodyLarge : typography.bodyMedium,
|
||||
lineHeight: inputLineHeight,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
maxHeight: inputLineHeight * 4,
|
||||
}),
|
||||
[typography, inputLineHeight, largeText],
|
||||
);
|
||||
const connectionNoticeTitleStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.titleSmall : typography.captionLarge,
|
||||
fontWeight: '700' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const connectionNoticeBodyStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
|
||||
lineHeight: typography.lineHeightLoose,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const sendButtonLabelStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodyLarge : typography.bodyMedium,
|
||||
fontWeight: '500' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const voiceRecordLabelStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodyLarge : typography.bodyMedium,
|
||||
lineHeight: largeText
|
||||
? typography.lineHeightLoose
|
||||
: typography.lineHeightNormal,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const voiceRecordDurationStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.captionLarge : typography.captionMedium,
|
||||
lineHeight: typography.lineHeightNormal,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
const statusBadgeTextStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: typography.captionMedium,
|
||||
lineHeight: typography.lineHeightNormal,
|
||||
fontFamily: 'monospace',
|
||||
color: CHAT_COLORS.onSurfaceVariant,
|
||||
}),
|
||||
[typography],
|
||||
);
|
||||
|
||||
const { data: messages } = useMessages(id);
|
||||
const ttsGate = useRef(createTtsPlaybackGate());
|
||||
const {
|
||||
@@ -878,6 +1159,7 @@ export default function ConversationScreen() {
|
||||
const {
|
||||
connectionState,
|
||||
streamingMessage,
|
||||
awaitingAssistantReply,
|
||||
sendText,
|
||||
sendVoiceMessage,
|
||||
sendTtsCancel,
|
||||
@@ -1083,7 +1365,9 @@ export default function ConversationScreen() {
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={styles.headerTitle}>{tApp('name')}</Text>
|
||||
<Text style={[styles.headerTitle, headerTitleFontStyle]}>
|
||||
{tApp('name')}
|
||||
</Text>
|
||||
{showConnectionBadge ? (
|
||||
<View
|
||||
style={[
|
||||
@@ -1095,6 +1379,7 @@ export default function ConversationScreen() {
|
||||
<Text
|
||||
style={[
|
||||
styles.statusBadgeText,
|
||||
statusBadgeTextStyle,
|
||||
connectionState === 'connected' &&
|
||||
styles.statusBadgeTextConnected,
|
||||
]}
|
||||
@@ -1130,6 +1415,10 @@ export default function ConversationScreen() {
|
||||
onPausePlayback={handlePausePlayback}
|
||||
onInterruptAssistantTts={handleInterruptAssistantTts}
|
||||
onReplayAssistantTts={handleReplayAssistantTts}
|
||||
bubbleTextStyle={chatBubbleTextStyle}
|
||||
voiceDurationTextStyle={chatVoiceDurationStyle}
|
||||
readAloudIconSize={chatReadAloudIconSize}
|
||||
readAloudButtonSize={chatReadAloudButtonSize}
|
||||
/>
|
||||
)}
|
||||
onContentSizeChange={() =>
|
||||
@@ -1138,19 +1427,28 @@ export default function ConversationScreen() {
|
||||
})
|
||||
}
|
||||
ListFooterComponent={
|
||||
streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
streamingTtsActive={
|
||||
!!streamingMessage &&
|
||||
playerStatus === 'playing' &&
|
||||
currentPlaybackItem?.kind === 'tts_auto'
|
||||
}
|
||||
onStreamingPress={handleInterruptAssistantTts}
|
||||
/>
|
||||
) : null
|
||||
<>
|
||||
{awaitingAssistantReply && !streamingMessage ? (
|
||||
<AssistantTypingBubble
|
||||
agentName={t('agentName')}
|
||||
labelStyle={chatTypingLabelStyle}
|
||||
/>
|
||||
) : null}
|
||||
{streamingMessage ? (
|
||||
<StreamingBubbles
|
||||
streamingText={streamingMessage.text}
|
||||
isComplete={streamingMessage.isComplete}
|
||||
agentName={t('agentName')}
|
||||
streamingTtsActive={
|
||||
!!streamingMessage &&
|
||||
playerStatus === 'playing' &&
|
||||
currentPlaybackItem?.kind === 'tts_auto'
|
||||
}
|
||||
onStreamingPress={handleInterruptAssistantTts}
|
||||
bubbleTextStyle={chatBubbleTextStyle}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1166,10 +1464,14 @@ export default function ConversationScreen() {
|
||||
>
|
||||
{showConnectionNotice ? (
|
||||
<View style={styles.connectionNotice}>
|
||||
<Text style={styles.connectionNoticeTitle}>
|
||||
<Text
|
||||
style={[styles.connectionNoticeTitle, connectionNoticeTitleStyle]}
|
||||
>
|
||||
{t('chatUnavailableTitle')}
|
||||
</Text>
|
||||
<Text style={styles.connectionNoticeText}>
|
||||
<Text
|
||||
style={[styles.connectionNoticeText, connectionNoticeBodyStyle]}
|
||||
>
|
||||
{connectionNoticeText}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -1179,6 +1481,8 @@ export default function ConversationScreen() {
|
||||
onChangeText={setInput}
|
||||
onSend={handleSend}
|
||||
textInputKey={inputResetKey}
|
||||
inputLineHeight={inputLineHeight}
|
||||
textInputStyle={inputTextStyle}
|
||||
inputMode={inputMode}
|
||||
onInputModeToggle={() => {
|
||||
setInputMode((m) => {
|
||||
@@ -1194,6 +1498,9 @@ export default function ConversationScreen() {
|
||||
onCancelRecording={() => void cancelRecording()}
|
||||
isRecording={isRecording}
|
||||
recordingDuration={recordingDuration}
|
||||
sendButtonLabelStyle={sendButtonLabelStyle}
|
||||
voiceRecordLabelStyle={voiceRecordLabelStyle}
|
||||
voiceRecordDurationStyle={voiceRecordDurationStyle}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
placeholderVoice={t('inputPlaceholderVoice')}
|
||||
addMoreLabel={t('addMore')}
|
||||
@@ -1237,7 +1544,6 @@ const styles = StyleSheet.create({
|
||||
gap: 10,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: CHAT_COLORS.primary,
|
||||
letterSpacing: -0.5,
|
||||
@@ -1314,6 +1620,15 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'flex-end',
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
/** 助手文字气泡:TTS 播放时用子级 absoluteFill Pressable 拦截点击,父级树始终为 View,避免跳变 */
|
||||
assistantBubbleWrap: {
|
||||
position: 'relative',
|
||||
alignSelf: 'flex-start',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
assistantTtsInterruptOverlayPressed: {
|
||||
backgroundColor: 'rgba(129, 119, 166, 0.12)',
|
||||
},
|
||||
/** 微信式:左白右主题色,靠头像一侧圆角略小;轻阴影替代像素风描边 */
|
||||
bubble: {
|
||||
paddingHorizontal: 14,
|
||||
@@ -1328,27 +1643,28 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
bubbleAgent: {
|
||||
backgroundColor: '#ffffff',
|
||||
/** 与 TTS 高亮同宽透明描边,避免播放时 border 从 0→1.5 导致布局跳变 */
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'transparent',
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
borderBottomRightRadius: 12,
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
bubbleAgentTtsActive: {
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(129, 119, 166, 0.5)',
|
||||
backgroundColor: 'rgba(231, 222, 255, 0.45)',
|
||||
},
|
||||
readingAloudCaption: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
marginTop: 6,
|
||||
color: CHAT_COLORS.primary,
|
||||
fontWeight: '500',
|
||||
typingDotsRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 4,
|
||||
},
|
||||
streamingTtsCaptionRow: {
|
||||
paddingLeft: 50,
|
||||
marginTop: 4,
|
||||
marginBottom: 8,
|
||||
typingHintText: {
|
||||
marginTop: 8,
|
||||
color: CHAT_COLORS.onSurfaceVariant,
|
||||
fontWeight: '500',
|
||||
},
|
||||
readAloudRow: {
|
||||
marginTop: 8,
|
||||
@@ -1356,30 +1672,13 @@ const styles = StyleSheet.create({
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: 'rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
/** 宽高由 MessageBubble 按大字模式传入,避免 iOS 上 Pressable 高度收缩 */
|
||||
readAloudButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
readAloudButtonInner: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
readAloudButtonLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: CHAT_COLORS.primary,
|
||||
},
|
||||
readAloudButtonLabelDisabled: {
|
||||
color: CHAT_COLORS.onSurfaceVariant,
|
||||
fontWeight: '500',
|
||||
},
|
||||
readAloudButtonDisabled: {
|
||||
opacity: 0.72,
|
||||
},
|
||||
bubbleUser: {
|
||||
backgroundColor: CHAT_COLORS.primary,
|
||||
borderTopLeftRadius: 12,
|
||||
@@ -1558,11 +1857,7 @@ const styles = StyleSheet.create({
|
||||
textAlignVertical: 'center',
|
||||
} as const,
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
lineHeight: CHAT_INPUT_LINE_H,
|
||||
color: CHAT_COLORS.onSurface,
|
||||
padding: 0,
|
||||
maxHeight: CHAT_INPUT_MAX_H,
|
||||
width: '100%',
|
||||
},
|
||||
sendButton: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Alert, Pressable, ScrollView, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { TFunction } from 'i18next';
|
||||
@@ -13,11 +14,13 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { NetworkError } from '@/core/api/types';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { conversationApi } from '@/features/conversation/api';
|
||||
import {
|
||||
useConversations,
|
||||
useCreateConversation,
|
||||
useDeleteConversation,
|
||||
} from '@/features/conversation/hooks';
|
||||
import { conversationKeys } from '@/features/conversation/query-keys';
|
||||
import type { ConversationListItem } from '@/features/conversation/types';
|
||||
|
||||
/** 与聊天页一致:archive Android `avatar_assistant` */
|
||||
@@ -209,33 +212,72 @@ function SwipeableConversationCard({
|
||||
|
||||
const SKELETON_COUNT = 3;
|
||||
|
||||
/** 列表按最近活动排序,取第一条尚无用户消息的对话,用于「打个招呼」复用 */
|
||||
function findReusableEmptyConversationId(
|
||||
items: ConversationListItem[],
|
||||
): string | null {
|
||||
const found = items.find((c) => c.hasUserMessage === false);
|
||||
return found?.id ?? null;
|
||||
}
|
||||
|
||||
export default function ConversationsScreen() {
|
||||
const { t } = useTranslation('conversation');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: conversations = [], isLoading } = useConversations();
|
||||
const createConversation = useCreateConversation();
|
||||
const createOnceGuardRef = useRef(false);
|
||||
|
||||
const isEmpty = conversations.length === 0;
|
||||
|
||||
const greetingSubtitle = isEmpty
|
||||
? t('emptyGreetingSubtitle')
|
||||
: t('startNewSubtitle');
|
||||
|
||||
const handleCreateConversation = () => {
|
||||
createConversation.mutate(undefined, {
|
||||
onSuccess: (result) => {
|
||||
router.push(`/(main)/conversation/${result.id}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
const msg =
|
||||
err instanceof NetworkError
|
||||
? t('createError')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('createError');
|
||||
Alert.alert(t('chatTitle'), msg, [{ text: t('confirm') }]);
|
||||
},
|
||||
});
|
||||
if (createConversation.isPending || createOnceGuardRef.current) {
|
||||
return;
|
||||
}
|
||||
createOnceGuardRef.current = true;
|
||||
|
||||
const tryReuseOrCreate = async () => {
|
||||
try {
|
||||
const fresh = await queryClient.fetchQuery({
|
||||
queryKey: conversationKeys.lists(),
|
||||
queryFn: () => conversationApi.list(),
|
||||
});
|
||||
const reuseId = findReusableEmptyConversationId(fresh ?? []);
|
||||
if (reuseId) {
|
||||
createOnceGuardRef.current = false;
|
||||
router.push(`/(main)/conversation/${reuseId}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// 列表刷新失败时仍尝试创建新对话
|
||||
}
|
||||
|
||||
createConversation.mutate(undefined, {
|
||||
onSuccess: (result) => {
|
||||
createOnceGuardRef.current = false;
|
||||
router.push(`/(main)/conversation/${result.id}`);
|
||||
},
|
||||
onError: (err) => {
|
||||
createOnceGuardRef.current = false;
|
||||
const msg =
|
||||
err instanceof NetworkError
|
||||
? t('createError')
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: t('createError');
|
||||
Alert.alert(t('chatTitle'), msg, [{ text: t('confirm') }]);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
void tryReuseOrCreate();
|
||||
};
|
||||
|
||||
const handleResumeLatestConversation = () => {
|
||||
const latest = conversations[0];
|
||||
if (latest) {
|
||||
router.push(`/(main)/conversation/${latest.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConversationPress = (id: string) => {
|
||||
@@ -295,8 +337,7 @@ export default function ConversationsScreen() {
|
||||
<>
|
||||
<Pressable
|
||||
className="items-center gap-6 rounded-2xl bg-muted/30 p-6 active:opacity-90"
|
||||
onPress={handleCreateConversation}
|
||||
disabled={createConversation.isPending}
|
||||
onPress={handleResumeLatestConversation}
|
||||
>
|
||||
<Icon
|
||||
as={MessageCirclePlus}
|
||||
@@ -309,10 +350,10 @@ export default function ConversationsScreen() {
|
||||
className="text-center font-display text-primary"
|
||||
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
|
||||
>
|
||||
{t('greetingTitle')}
|
||||
{t('resumeChatTitle')}
|
||||
</Text>
|
||||
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
|
||||
{greetingSubtitle}
|
||||
{t('resumeChatSubtitle')}
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
|
||||
@@ -100,7 +100,7 @@ function updateListPreview(
|
||||
queryClient: QueryClient,
|
||||
conversationId: string,
|
||||
text: string,
|
||||
_senderType: 'user' | 'assistant',
|
||||
senderType: 'user' | 'assistant',
|
||||
): void {
|
||||
queryClient.setQueryData<ConversationListItem[]>(
|
||||
conversationKeys.lists(),
|
||||
@@ -112,6 +112,7 @@ function updateListPreview(
|
||||
...item,
|
||||
latestMessagePreview: text.slice(0, 50),
|
||||
latestMessageTime: nowMs(),
|
||||
...(senderType === 'user' ? { hasUserMessage: true } : {}),
|
||||
}
|
||||
: item,
|
||||
);
|
||||
|
||||
@@ -65,6 +65,7 @@ export function useCreateConversation() {
|
||||
latestMessageTime: Date.now(),
|
||||
unreadCount: 0,
|
||||
isDefaultAssistant: true,
|
||||
hasUserMessage: false,
|
||||
};
|
||||
return [item, ...(old ?? [])];
|
||||
},
|
||||
@@ -134,6 +135,8 @@ function generateUUID(): string {
|
||||
interface RealtimeSessionState {
|
||||
connectionState: WsConnectionState;
|
||||
streamingMessage: StreamingAgentMessage | null;
|
||||
/** 已发出用户消息,尚未收到助手首段流式文本(用于「正在回复」气泡) */
|
||||
awaitingAssistantReply: boolean;
|
||||
error: string | null;
|
||||
sendText: (text: string) => void;
|
||||
sendVoiceMessage: (uri: string, durationMs: number) => Promise<boolean>;
|
||||
@@ -154,12 +157,17 @@ export function useRealtimeSession({
|
||||
useState<WsConnectionState>('disconnected');
|
||||
const [streamingMessage, setStreamingMessage] =
|
||||
useState<StreamingAgentMessage | null>(null);
|
||||
const [awaitingAssistantReply, setAwaitingAssistantReply] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleStreamingText: StreamingTextCallback = useCallback(
|
||||
(text, isComplete) => {
|
||||
if (text.trim().length > 0) {
|
||||
setAwaitingAssistantReply(false);
|
||||
}
|
||||
if (isComplete) {
|
||||
setStreamingMessage(null);
|
||||
setAwaitingAssistantReply(false);
|
||||
return;
|
||||
}
|
||||
setStreamingMessage({ text, isComplete });
|
||||
@@ -168,6 +176,7 @@ export function useRealtimeSession({
|
||||
);
|
||||
|
||||
const handleError: ErrorCallback = useCallback((message) => {
|
||||
setAwaitingAssistantReply(false);
|
||||
setError(message);
|
||||
}, []);
|
||||
|
||||
@@ -191,6 +200,7 @@ export function useRealtimeSession({
|
||||
sessionRef.current = null;
|
||||
setConnectionState('disconnected');
|
||||
setStreamingMessage(null);
|
||||
setAwaitingAssistantReply(false);
|
||||
};
|
||||
}, [
|
||||
conversationId,
|
||||
@@ -211,6 +221,7 @@ export function useRealtimeSession({
|
||||
return;
|
||||
}
|
||||
|
||||
setAwaitingAssistantReply(true);
|
||||
onTtsPlaybackResume?.();
|
||||
|
||||
const localId = `pending_${Date.now()}`;
|
||||
@@ -229,6 +240,18 @@ export function useRealtimeSession({
|
||||
return [...(old ?? []), msg];
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueryData<ConversationListItem[]>(
|
||||
conversationKeys.lists(),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return old.map((item) =>
|
||||
item.id === conversationId
|
||||
? { ...item, hasUserMessage: true }
|
||||
: item,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
[conversationId, queryClient, onTtsPlaybackResume],
|
||||
);
|
||||
@@ -258,6 +281,7 @@ export function useRealtimeSession({
|
||||
return false;
|
||||
}
|
||||
|
||||
setAwaitingAssistantReply(true);
|
||||
const localId = `pending_voice_${Date.now()}`;
|
||||
await voiceSegmentStore.recordSentSegment({
|
||||
voiceSessionId,
|
||||
@@ -282,6 +306,18 @@ export function useRealtimeSession({
|
||||
return [...(old ?? []), msg];
|
||||
},
|
||||
);
|
||||
|
||||
queryClient.setQueryData<ConversationListItem[]>(
|
||||
conversationKeys.lists(),
|
||||
(old) => {
|
||||
if (!old) return old;
|
||||
return old.map((item) =>
|
||||
item.id === conversationId
|
||||
? { ...item, hasUserMessage: true }
|
||||
: item,
|
||||
);
|
||||
},
|
||||
);
|
||||
onTtsPlaybackResume?.();
|
||||
return true;
|
||||
} catch {
|
||||
@@ -303,6 +339,7 @@ export function useRealtimeSession({
|
||||
return {
|
||||
connectionState,
|
||||
streamingMessage,
|
||||
awaitingAssistantReply,
|
||||
error,
|
||||
sendText,
|
||||
sendVoiceMessage,
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ConversationListItem {
|
||||
latestMessageTime: number;
|
||||
unreadCount: number;
|
||||
isDefaultAssistant: boolean;
|
||||
/** 是否已有用户发出的文本或语音(仅助手/空会话为 false,用于「打个招呼」复用同一会话) */
|
||||
hasUserMessage: boolean;
|
||||
}
|
||||
|
||||
export interface ConversationDetail {
|
||||
|
||||
@@ -189,8 +189,9 @@ const FONT_FAMILIES = {
|
||||
}) ?? 'sans-serif',
|
||||
};
|
||||
|
||||
const FONT_SIZES = { small: 16, default: 20, large: 24 };
|
||||
const LINE_HEIGHTS = { small: 30, default: 38, large: 44 };
|
||||
/** 阅读页三档字号(再次上调,便于长时间阅读) */
|
||||
const FONT_SIZES = { small: 22, default: 27, large: 33 };
|
||||
const LINE_HEIGHTS = { small: 40, default: 50, large: 60 };
|
||||
|
||||
export interface MarkdownRendererProps {
|
||||
markdown: string;
|
||||
|
||||
@@ -63,6 +63,7 @@ interface Resources {
|
||||
conversation: {
|
||||
addMore: 'More';
|
||||
agentName: 'Life Echo';
|
||||
assistantReplying: 'Replying…';
|
||||
cancel: 'Cancel';
|
||||
cancelRecording: 'Cancel recording';
|
||||
cannotReadAloud: 'Read unavailable';
|
||||
@@ -88,6 +89,8 @@ interface Resources {
|
||||
readingAloud: 'Reading aloud…';
|
||||
recentChats: 'Recent Chats';
|
||||
recordingPermissionDenied: 'Microphone permission is required to record';
|
||||
resumeChatSubtitle: 'Open your latest conversation to keep talking.';
|
||||
resumeChatTitle: 'Continue chatting';
|
||||
send: 'Send';
|
||||
startNewSubtitle: 'Capture a new memory or share your thoughts with your companion.';
|
||||
stopReadingAloud: 'Stop reading aloud';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"deleteConversation": "Delete Conversation",
|
||||
"addMore": "More",
|
||||
"agentName": "Life Echo",
|
||||
"assistantReplying": "Replying…",
|
||||
"cancelRecording": "Cancel recording",
|
||||
"chatTitle": "Conversation",
|
||||
"connectionConnected": "Connected",
|
||||
@@ -18,6 +19,8 @@
|
||||
"connectionDisconnected": "Disconnected",
|
||||
"emptyGreetingSubtitle": "Chat with your companion and record your stories.",
|
||||
"greetingTitle": "Say Hello",
|
||||
"resumeChatTitle": "Continue chatting",
|
||||
"resumeChatSubtitle": "Open your latest conversation to keep talking.",
|
||||
"inputPlaceholder": "Type a message...",
|
||||
"inputPlaceholderVoice": "Type here or hold the mic to speak...",
|
||||
"me": "Me",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"deleteConversation": "删除对话",
|
||||
"addMore": "更多功能",
|
||||
"agentName": "岁月知己",
|
||||
"assistantReplying": "正在回复…",
|
||||
"cancelRecording": "取消录音发送",
|
||||
"chatTitle": "对话",
|
||||
"connectionConnected": "已连接",
|
||||
@@ -18,6 +19,8 @@
|
||||
"connectionDisconnected": "未连接",
|
||||
"emptyGreetingSubtitle": "和岁月知己聊聊,记录你的故事。",
|
||||
"greetingTitle": "打个招呼",
|
||||
"resumeChatTitle": "继续对话",
|
||||
"resumeChatSubtitle": "进入最近一条对话,接着和岁月知己聊。",
|
||||
"inputPlaceholder": "输入消息...",
|
||||
"inputPlaceholderVoice": "点击这里输入,或者按住左边说话...",
|
||||
"me": "我",
|
||||
|
||||
Reference in New Issue
Block a user