重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复

本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括:

1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。

2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。

3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。

4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。

5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。

6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。

7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
Kevin
2026-03-20 10:30:07 +08:00
parent 13e3124b85
commit 7f57f96c25
67 changed files with 4751 additions and 832 deletions

View File

@@ -1,8 +1,6 @@
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router, useLocalSearchParams } from 'expo-router';
import { Settings, Trash2, X } from 'lucide-react-native';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import {
ActivityIndicator,
Alert,
@@ -10,7 +8,6 @@ import {
Platform,
Pressable,
ScrollView,
StyleSheet,
useWindowDimensions,
View,
} from 'react-native';
@@ -21,6 +18,8 @@ import { Icon } from '@/components/ui/icon';
import { Text } from '@/components/ui/text';
import { ScreenHeader } from '@/components/screen-header';
import { ScreenGutter } from '@/constants/layout';
import { MarkdownRenderer } from '@/features/memoir/markdown-renderer';
import { cn } from '@/lib/utils';
import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks';
// Life-Echo reading colors (from HTML reference)
@@ -39,18 +38,6 @@ type FontSize = 'small' | 'default' | 'large';
type FontFamily = 'serif' | 'sans';
type BackgroundTheme = 'white' | 'sepia';
const FONT_SIZES: Record<FontSize, number> = {
small: 16,
default: 20,
large: 24,
};
const LINE_HEIGHTS: Record<FontSize, number> = {
small: 30,
default: 38,
large: 44,
};
const FONT_FAMILIES: Record<FontFamily, string> = {
serif:
Platform.select({ ios: 'Georgia', android: 'serif', default: 'serif' }) ??
@@ -68,198 +55,6 @@ const BACKGROUND_COLORS: Record<BackgroundTheme, string> = {
sepia: READING_COLORS.backgroundSepia,
};
function ChapterContent({
sections,
coverImageUrl,
fontSize,
fontFamily,
backgroundColor,
}: {
sections: { content: string; image: { url: string | null } | null }[];
coverImageUrl: string | null;
fontSize: FontSize;
fontFamily: FontFamily;
backgroundColor: BackgroundTheme;
}) {
const { width } = useWindowDimensions();
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
const heroAspectRatio = 4 / 5;
const bodySize = FONT_SIZES[fontSize];
const lineHeight = LINE_HEIGHTS[fontSize];
const fontFam = FONT_FAMILIES[fontFamily];
const bgColor = BACKGROUND_COLORS[backgroundColor];
const [heroLoadFailed, setHeroLoadFailed] = useState(false);
useEffect(() => {
setHeroLoadFailed(false);
}, [coverImageUrl]);
const hasCoverImage = !!coverImageUrl && !heroLoadFailed;
return (
<>
{/* Hero Image: 仅在有封面图且加载成功时显示,避免无图或加载失败时大片空白 */}
{hasCoverImage && (
<View
style={{
width: '100%',
aspectRatio: heroAspectRatio,
overflow: 'hidden',
}}
>
<Image
source={{ uri: coverImageUrl! }}
alt="Chapter hero"
onError={() => setHeroLoadFailed(true)}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
<LinearGradient
colors={['transparent', bgColor]}
locations={[0.3, 1]}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
}}
/>
</View>
)}
{/* Memoir Content */}
<View
style={{
paddingHorizontal: ScreenGutter,
marginTop: hasCoverImage ? -60 : 0,
paddingTop: hasCoverImage ? 0 : 12,
paddingBottom: 48,
backgroundColor: bgColor,
}}
>
<View
style={{ maxWidth: contentWidth, alignSelf: 'center', width: '100%' }}
>
{(() => {
const indent = '\u3000\u3000';
let isFirstParagraph = true;
return sections.flatMap((section, i) => {
const text = section.content?.trim() ?? '';
const hasImage = !!section.image?.url;
if (!text && !hasImage) return [];
const paragraphs = text
? text
.split(/\n\n+/)
.map((p) => p.trim())
.filter(Boolean)
: [];
const nodes: React.ReactNode[] = [];
paragraphs.forEach((para, pIdx) => {
const firstChar = para.charAt(0);
const rest = para.slice(1);
const isLastInSection =
pIdx === paragraphs.length - 1 && !hasImage;
const mb = isLastInSection ? 20 : 16;
if (isFirstParagraph && firstChar) {
const firstParaLineHeight = Math.round(lineHeight * 1.35);
nodes.push(
<Text
key={`${i}-${pIdx}`}
selectable
style={{
fontSize: bodySize,
lineHeight: firstParaLineHeight,
color: READING_COLORS.onSurfaceVariant,
fontFamily: fontFam,
marginBottom: mb,
}}
>
<Text
style={{
fontSize: bodySize * 2.2,
fontWeight: '700',
color: READING_COLORS.primary,
lineHeight: firstParaLineHeight,
fontFamily: fontFam,
}}
>
{firstChar}
</Text>
{rest}
</Text>,
);
isFirstParagraph = false;
} else {
nodes.push(
<Text
key={`${i}-${pIdx}`}
selectable
style={{
fontSize: bodySize,
lineHeight,
color: READING_COLORS.onSurfaceVariant,
fontFamily: fontFam,
marginBottom: mb,
}}
>
{indent}
{para}
</Text>,
);
}
});
if (hasImage) {
nodes.push(
<Image
key={`${i}-img`}
source={{ uri: section.image!.url }}
style={{
width: '100%',
aspectRatio: 16 / 9,
borderRadius: 12,
marginTop: paragraphs.length > 0 ? 12 : 0,
marginBottom: 20,
}}
/>,
);
}
return nodes;
});
})()}
{sections.length > 0 && (
<View
style={{
paddingTop: 24,
paddingBottom: 24,
alignItems: 'center',
}}
>
<View
style={{
width: 96,
height: 1,
backgroundColor: READING_COLORS.divider,
}}
/>
</View>
)}
</View>
</View>
</>
);
}
function ReadingSettingsModal({
visible,
onClose,
@@ -290,53 +85,55 @@ function ReadingSettingsModal({
transparent
onRequestClose={onClose}
>
<Pressable style={readingSettingsStyles.backdrop} onPress={onClose}>
<Pressable className="flex-1 justify-end bg-black/40" onPress={onClose}>
<Pressable
style={[
readingSettingsStyles.sheet,
{ paddingBottom: insets.bottom + 24 },
]}
className="rounded-t-2xl bg-card px-screen-gutter pt-2 shadow-lg"
style={{ paddingBottom: insets.bottom + 24 }}
onPress={() => {}}
>
<View style={readingSettingsStyles.handle} />
<View style={readingSettingsStyles.header}>
<Text style={readingSettingsStyles.title}>
<View className="mb-4 h-1 w-10 self-center rounded-full bg-black/20" />
<View className="mb-5 flex-row items-center justify-between">
<Text
variant="titleMedium"
className="font-semibold text-foreground"
>
{t('chapterReading.readingSettings')}
</Text>
<Pressable
onPress={onClose}
hitSlop={12}
style={({ pressed }) => [
readingSettingsStyles.closeBtn,
pressed && { opacity: 0.6 },
]}
className="p-2 active:opacity-60"
accessibilityLabel={t('chapterReading.close')}
accessibilityRole="button"
>
<Icon as={X} size={22} color={READING_COLORS.onSurfaceVariant} />
<Icon as={X} size={22} className="text-muted-foreground" />
</Pressable>
</View>
<View style={readingSettingsStyles.section}>
<Text style={readingSettingsStyles.label}>
<View className="mb-5">
<Text
variant="sectionTitle"
className="mb-2.5 font-semibold uppercase tracking-wider text-muted-foreground"
>
{t('chapterReading.fontSize')}
</Text>
<View style={readingSettingsStyles.segmented}>
<View className="flex-row rounded-[10px] bg-muted p-1">
{(['small', 'default', 'large'] as const).map((s) => (
<Pressable
key={s}
onPress={() => onFontSizeChange(s)}
style={({ pressed }) => [
readingSettingsStyles.segItem,
fontSize === s && readingSettingsStyles.segItemActive,
pressed && { opacity: 0.8 },
]}
className={cn(
'min-h-11 flex-1 items-center justify-center rounded-lg',
fontSize === s && 'bg-primary',
'active:opacity-80',
)}
>
<Text
style={[
readingSettingsStyles.segText,
fontSize === s && readingSettingsStyles.segTextActive,
]}
variant="bodyMedium"
className={cn(
'font-medium text-muted-foreground',
fontSize === s && 'font-semibold text-primary-foreground',
)}
>
{t(
`chapterReading.fontSize${s.charAt(0).toUpperCase() + s.slice(1)}`,
@@ -347,27 +144,32 @@ function ReadingSettingsModal({
</View>
</View>
<View style={readingSettingsStyles.section}>
<Text style={readingSettingsStyles.label}>
<View className="mb-5">
<Text
variant="sectionTitle"
className="mb-2.5 font-semibold uppercase tracking-wider text-muted-foreground"
>
{t('chapterReading.typography')}
</Text>
<View style={readingSettingsStyles.segmented}>
<View className="flex-row rounded-[10px] bg-muted p-1">
{(['serif', 'sans'] as const).map((f) => (
<Pressable
key={f}
onPress={() => onFontFamilyChange(f)}
style={({ pressed }) => [
readingSettingsStyles.segItem,
fontFamily === f && readingSettingsStyles.segItemActive,
pressed && { opacity: 0.8 },
]}
className={cn(
'min-h-11 flex-1 items-center justify-center rounded-lg',
fontFamily === f && 'bg-primary',
'active:opacity-80',
)}
>
<Text
style={[
readingSettingsStyles.segText,
fontFamily === f && readingSettingsStyles.segTextActive,
{ fontFamily: FONT_FAMILIES[f] },
]}
variant="bodyMedium"
style={{ fontFamily: FONT_FAMILIES[f] }}
className={cn(
'font-medium text-muted-foreground',
fontFamily === f &&
'font-semibold text-primary-foreground',
)}
>
{t(
`chapterReading.font${f.charAt(0).toUpperCase() + f.slice(1)}`,
@@ -378,26 +180,28 @@ function ReadingSettingsModal({
</View>
</View>
<View style={readingSettingsStyles.section}>
<Text style={readingSettingsStyles.label}>
<View className="mb-5">
<Text
variant="sectionTitle"
className="mb-2.5 font-semibold uppercase tracking-wider text-muted-foreground"
>
{t('chapterReading.backgroundColor')}
</Text>
<View style={readingSettingsStyles.bgRow}>
<View className="flex-row gap-3">
{(['white', 'sepia'] as const).map((theme) => (
<Pressable
key={theme}
onPress={() => onBackgroundChange(theme)}
style={({ pressed }) => [
readingSettingsStyles.bgOption,
{ backgroundColor: BACKGROUND_COLORS[theme] },
backgroundColor === theme &&
readingSettingsStyles.bgOptionActive,
pressed && { opacity: 0.9 },
]}
className={cn(
'min-h-16 flex-1 flex-row items-center justify-center gap-2.5 rounded-[10px] border-2 border-transparent',
backgroundColor === theme && 'border-primary',
'active:opacity-90',
)}
style={{ backgroundColor: BACKGROUND_COLORS[theme] }}
>
<View
className="h-7 w-7 rounded-md border border-black/10"
style={[
readingSettingsStyles.bgSwatch,
{ backgroundColor: BACKGROUND_COLORS[theme] },
theme === 'sepia' && {
borderColor: 'rgba(91,77,62,0.5)',
@@ -405,12 +209,13 @@ function ReadingSettingsModal({
]}
/>
<Text
style={[
readingSettingsStyles.bgLabel,
theme === 'sepia' && { color: '#5B4D3E' },
backgroundColor === theme &&
readingSettingsStyles.bgLabelActive,
]}
variant="bodyMedium"
className={cn(
'font-medium',
theme === 'white' && 'text-foreground',
theme === 'sepia' && 'text-[#5B4D3E]',
backgroundColor === theme && 'font-semibold',
)}
>
{t(
`chapterReading.bg${theme === 'white' ? 'PureWhite' : 'Sepia'}`,
@@ -426,117 +231,13 @@ function ReadingSettingsModal({
);
}
const readingSettingsStyles = StyleSheet.create({
backdrop: {
flex: 1,
justifyContent: 'flex-end',
backgroundColor: 'rgba(0,0,0,0.4)',
},
sheet: {
backgroundColor: '#fff',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
paddingTop: 8,
paddingHorizontal: ScreenGutter,
...Platform.select({
ios: {
shadowColor: '#000',
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.06,
shadowRadius: 12,
},
android: { elevation: 12 },
}),
},
handle: {
alignSelf: 'center',
width: 40,
height: 4,
borderRadius: 2,
backgroundColor: 'rgba(0,0,0,0.2)',
marginBottom: 16,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 20,
},
title: {
fontSize: 17,
fontWeight: '700',
color: READING_COLORS.onSurface,
},
closeBtn: { padding: 8 },
label: {
fontSize: 11,
fontWeight: '600',
letterSpacing: 0.5,
color: READING_COLORS.onSurfaceVariant,
marginBottom: 10,
textTransform: 'uppercase',
},
section: { marginBottom: 20 },
segmented: {
flexDirection: 'row',
backgroundColor: '#eeeef0',
borderRadius: 10,
padding: 4,
},
segItem: {
flex: 1,
minHeight: 44,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 8,
},
segItemActive: {
backgroundColor: READING_COLORS.primary,
},
segText: {
fontSize: 14,
fontWeight: '500',
color: READING_COLORS.onSurfaceVariant,
},
segTextActive: {
fontWeight: '700',
color: '#fff',
},
bgRow: { flexDirection: 'row', gap: 12 },
bgOption: {
flex: 1,
minHeight: 64,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 10,
borderRadius: 10,
borderWidth: 2,
borderColor: 'transparent',
},
bgOptionActive: {
borderColor: READING_COLORS.primary,
},
bgSwatch: {
width: 28,
height: 28,
borderRadius: 6,
borderWidth: 1,
borderColor: 'rgba(0,0,0,0.1)',
},
bgLabel: {
fontSize: 14,
fontWeight: '500',
color: READING_COLORS.onSurface,
},
bgLabelActive: { fontWeight: '700' },
});
export default function ChapterScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const insets = useSafeAreaInsets();
const { width } = useWindowDimensions();
const { t } = useTranslation('memoir');
const { data: chapter, isLoading } = useChapterDetail(id ?? '');
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
const deleteChapter = useDeleteChapter();
const [settingsVisible, setSettingsVisible] = useState(false);
@@ -602,8 +303,9 @@ export default function ChapterScreen() {
);
}
const sections = chapter.sections ?? [];
const coverImageUrl = chapter.cover_image?.url ?? null;
const canonicalMarkdown = (chapter.canonical_markdown ?? '').trim();
const renderedAssets = chapter.rendered_assets ?? chapter.images ?? [];
const handleDeletePress = () => {
Alert.alert(
@@ -677,12 +379,14 @@ export default function ChapterScreen() {
showsVerticalScrollIndicator={false}
style={{ backgroundColor: bgColor }}
>
<ChapterContent
sections={sections}
<MarkdownRenderer
markdown={canonicalMarkdown}
renderedAssets={renderedAssets}
coverImageUrl={coverImageUrl}
fontSize={fontSize}
fontFamily={fontFamily}
backgroundColor={backgroundColor}
backgroundColor={bgColor}
contentWidth={contentWidth}
/>
</ScrollView>

View File

@@ -20,6 +20,7 @@ import {
Platform,
Pressable,
StyleSheet,
Text as RNText,
TextInput,
View,
} from 'react-native';
@@ -350,16 +351,32 @@ function VoiceRecordButton({
]}
disabled={!enabled}
>
<Text
style={[
styles.voiceRecordLabel,
!enabled && styles.voiceRecordLabelDisabled,
isRecording && styles.voiceRecordLabelRecording,
]}
numberOfLines={1}
>
{isRecording ? tapToEndLabel : tapToStartLabel}
</Text>
{isRecording ? (
<View style={styles.voiceRecordLabelCenterWrap}>
<Text
style={[
styles.voiceRecordLabel,
styles.voiceRecordLabelCenter,
styles.voiceRecordLabelRecording,
!enabled && styles.voiceRecordLabelDisabled,
]}
numberOfLines={1}
>
{tapToEndLabel}
</Text>
</View>
) : (
<Text
style={[
styles.voiceRecordLabel,
!enabled && styles.voiceRecordLabelDisabled,
styles.voiceRecordLabelCenter,
]}
numberOfLines={1}
>
{tapToStartLabel}
</Text>
)}
{isRecording && (
<View style={styles.voiceRecordPill}>
<Animated.View
@@ -368,11 +385,16 @@ function VoiceRecordButton({
{ transform: [{ scale: pulseAnim }] },
]}
/>
{/* TODO: Duration number centering still broken on Android */}
<View style={styles.voiceRecordDurationWrap}>
<Text style={styles.voiceRecordDuration}>
<RNText
style={[
styles.voiceRecordDuration,
Platform.OS === 'android' && styles.voiceRecordDurationAndroid,
]}
{...(Platform.OS === 'android' && { includeFontPadding: false })}
>
{formatRecordingDuration(recordingDuration)}
</Text>
</RNText>
</View>
</View>
)}
@@ -505,7 +527,7 @@ function ChatInputBar({
accessibilityRole="button"
>
<Text
style={[styles.sendButtonText, { color: colors.primaryForeground }]}
style={[styles.sendButtonText, { color: CHAT_COLORS.onSurface }]}
>
{sendLabel}
</Text>
@@ -580,11 +602,21 @@ export default function ConversationScreen() {
const [input, setInput] = useState('');
const [inputMode, setInputMode] = useState<InputMode>('text');
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const listRef = useRef<FlatList>(null);
useEffect(() => {
const onShow = () => setIsKeyboardVisible(true);
const onHide = () => setIsKeyboardVisible(false);
const onShow = (e: { endCoordinates: { height: number } }) => {
setIsKeyboardVisible(true);
setKeyboardHeight(e.endCoordinates.height);
InteractionManager.runAfterInteractions(() => {
listRef.current?.scrollToEnd({ animated: true });
});
};
const onHide = () => {
setIsKeyboardVisible(false);
setKeyboardHeight(0);
};
const subShow = Keyboard.addListener('keyboardDidShow', onShow);
const subHide = Keyboard.addListener('keyboardDidHide', onHide);
return () => {
@@ -621,11 +653,12 @@ export default function ConversationScreen() {
const keyboardOffset = Platform.OS === 'ios' ? insets.top + 56 : 0;
const kavEnabled = inputMode === 'text' && isKeyboardVisible;
const kavBehavior = Platform.OS === 'ios' ? 'padding' : 'height';
return (
<KeyboardAvoidingView
style={styles.container}
behavior={kavEnabled ? 'padding' : undefined}
behavior={kavEnabled ? kavBehavior : undefined}
keyboardVerticalOffset={keyboardOffset}
>
<View style={styles.column}>
@@ -668,8 +701,14 @@ export default function ConversationScreen() {
<FlatList
ref={listRef}
style={styles.list}
contentContainerStyle={styles.listContent}
contentContainerStyle={[
styles.listContent,
inputMode === 'text' &&
isKeyboardVisible && { paddingBottom: 12 + keyboardHeight },
]}
data={flattenedData}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
keyExtractor={(item) => item.listKey}
renderItem={({ item }) => (
<MessageBubble
@@ -784,6 +823,7 @@ const styles = StyleSheet.create({
flex: 1,
},
listContent: {
flexGrow: 1,
paddingHorizontal: 12,
paddingTop: 16,
paddingBottom: 12,
@@ -952,6 +992,14 @@ const styles = StyleSheet.create({
color: 'rgba(27, 27, 31, 0.72)',
flex: 1,
},
voiceRecordLabelCenter: {
textAlign: 'center',
},
voiceRecordLabelCenterWrap: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
voiceRecordLabelRecording: {
color: 'rgba(27, 27, 31, 0.92)',
},
@@ -980,8 +1028,13 @@ const styles = StyleSheet.create({
},
voiceRecordDuration: {
fontSize: 12,
lineHeight: 12,
color: 'rgba(27, 27, 31, 0.86)',
textAlign: 'center',
},
voiceRecordDurationAndroid: {
textAlignVertical: 'center',
} as const,
textInput: {
fontSize: 16,
lineHeight: 22,