feat(memoir+conversation): 章节/故事最小可读字数;会话 hasUserMessage 与 UI 优化

- 后端:300 字门槛统一物化、hydrate、列表/PDF/详情;过短章节对读者隐藏
- 对话:首包前打字动画、大字模式排版、朗读/TTS 交互与布局稳定
- 首页:复用无用户消息会话;空列表「继续对话」与文案 i18n
- 章节阅读:标题进正文、封面与去重标题;阅读 Markdown 字号上调
This commit is contained in:
Kevin
2026-03-26 16:28:33 +08:00
parent d990399112
commit 1374f6e8f5
15 changed files with 708 additions and 198 deletions

View File

@@ -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>

View File

@@ -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: {

View File

@@ -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>

View File

@@ -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,
);

View File

@@ -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,

View File

@@ -20,6 +20,8 @@ export interface ConversationListItem {
latestMessageTime: number;
unreadCount: number;
isDefaultAssistant: boolean;
/** 是否已有用户发出的文本或语音(仅助手/空会话为 false用于「打个招呼」复用同一会话 */
hasUserMessage: boolean;
}
export interface ConversationDetail {

View File

@@ -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;

View File

@@ -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';

View File

@@ -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",

View File

@@ -11,6 +11,7 @@
"deleteConversation": "删除对话",
"addMore": "更多功能",
"agentName": "岁月知己",
"assistantReplying": "正在回复…",
"cancelRecording": "取消录音发送",
"chatTitle": "对话",
"connectionConnected": "已连接",
@@ -18,6 +19,8 @@
"connectionDisconnected": "未连接",
"emptyGreetingSubtitle": "和岁月知己聊聊,记录你的故事。",
"greetingTitle": "打个招呼",
"resumeChatTitle": "继续对话",
"resumeChatSubtitle": "进入最近一条对话,接着和岁月知己聊。",
"inputPlaceholder": "输入消息...",
"inputPlaceholderVoice": "点击这里输入,或者按住左边说话...",
"me": "我",