重构回忆录为 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

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