diff --git a/api/app/agents/prompts/memory_prompts.py b/api/app/agents/prompts/memory_prompts.py index 3e78d68..4d2aa85 100644 --- a/api/app/agents/prompts/memory_prompts.py +++ b/api/app/agents/prompts/memory_prompts.py @@ -302,6 +302,7 @@ def get_narrative_prompt( existing_content: str = "", user_profile: str = "", birth_year: Optional[int] = None, + archived_summaries: str = "", ) -> str: """将新对话改写为叙述(只输出新内容的改写,不重复已有内容)""" context_tail = "" @@ -309,6 +310,7 @@ def get_narrative_prompt( context_tail = existing_content[-300:] if len(existing_content) > 300 else existing_content context_section = f"\n\n【衔接上下文(已有内容的末尾,仅供参考衔接,不要重复)】:\n{context_tail}" if context_tail else "" + archived_section = f"\n\n【已删除的该类别历史章节(仅供参考,请勿直接使用或重复)】:\n{archived_summaries}" if archived_summaries else "" profile_section = f"\n\n用户基本信息:\n{user_profile}" if user_profile else "" age_hint = _build_age_hint(stage, birth_year) @@ -323,6 +325,7 @@ def get_narrative_prompt( 新的对话内容: {new_content} {context_section} +{archived_section} ## 第一步:提炼核心内容 在改写之前,请先从对话内容中提炼出与人生经历相关的核心信息: diff --git a/api/app/features/memoir/repo.py b/api/app/features/memoir/repo.py index b05af63..acfb26d 100644 --- a/api/app/features/memoir/repo.py +++ b/api/app/features/memoir/repo.py @@ -2,7 +2,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import Session, joinedload from app.features.memoir.models import Book, Chapter, ChapterSection, MemoirState @@ -56,3 +56,33 @@ async def get_memoir_state(user_id: str, db: AsyncSession) -> MemoirState | None stmt = select(MemoirState).where(MemoirState.user_id == user_id) result = await db.execute(stmt) return result.scalar_one_or_none() + + +def get_archived_chapter_summaries_sync( + session: Session, user_id: str, category: str +) -> list[tuple[str, str]]: + """获取已删除(is_active=False)的同类别章节的标题与内容摘要,供 AI 参考。""" + stmt = ( + select(Chapter) + .where( + Chapter.user_id == user_id, + Chapter.category == category, + Chapter.is_active == False, # noqa: E712 + ) + .options(joinedload(Chapter.sections)) + .order_by(Chapter.updated_at.desc()) + ) + result = session.execute(stmt) + chapters = list(result.unique().scalars().all()) + summaries: list[tuple[str, str]] = [] + for ch in chapters: + sections = getattr(ch, "sections", None) or [] + parts = [ + (s.content or "").strip() + for s in sorted(sections, key=lambda x: getattr(x, "order_index", 0)) + ] + combined = "".join(parts) + preview = (combined[:200] + "...") if len(combined) > 200 else combined + if preview.strip(): + summaries.append((ch.title or "", preview)) + return summaries diff --git a/api/app/features/memoir/service.py b/api/app/features/memoir/service.py index 1f82e15..9d81517 100644 --- a/api/app/features/memoir/service.py +++ b/api/app/features/memoir/service.py @@ -170,6 +170,8 @@ class MemoirService: raise HTTPException(status_code=404, detail="Chapter not found") if chapter.user_id != user_id: raise HTTPException(status_code=403, detail="无权访问此章节") + if not chapter.is_active: + raise HTTPException(status_code=404, detail="Chapter not found") await self._cleanup_unavailable_images(chapter) return chapter_to_dict(chapter) diff --git a/api/app/tasks/memoir_tasks.py b/api/app/tasks/memoir_tasks.py index 4bc24e6..35ff8af 100644 --- a/api/app/tasks/memoir_tasks.py +++ b/api/app/tasks/memoir_tasks.py @@ -23,6 +23,7 @@ from app.features.memoir.models import ( MemoirImage, MemoirState, ) +from app.features.memoir import repo as memoir_repo from app.features.user.models import User from app.core.dependencies import get_llm_provider from app.agents.state_schema import MemoirStateSchema, SlotData, default_state @@ -713,6 +714,12 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): ) narrative = combined_text + # 已删除章节摘要供 AI 参考 + archived = memoir_repo.get_archived_chapter_summaries_sync(db, user_id, chapter_category) + archived_summaries = "\n".join( + f"- 《{title_text}》:{preview}" for title_text, preview in archived + ) if archived else "" + if llm: try: if not chapter: @@ -733,6 +740,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]): existing_content=existing_content, user_profile=user_profile, birth_year=user_birth_year, + archived_summaries=archived_summaries, ) narrative_response = llm.invoke(narrative_prompt) new_narrative = narrative_response.content.strip() @@ -861,12 +869,18 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str): s.content for s in sorted(chapter.sections, key=lambda x: x.order_index) if (s.content or "").strip() ) + archived = memoir_repo.get_archived_chapter_summaries_sync(db, user_id, stage) + archived_summaries = "\n".join( + f"- 《{title_text}》:{preview}" for title_text, preview in archived + ) if archived else "" + if llm: prompt = get_narrative_prompt( stage=stage, slots={}, new_content=new_content, existing_content=existing_content, + archived_summaries=archived_summaries, ) response = llm.invoke(prompt) new_narrative = response.content.strip() diff --git a/app-expo/src/app/(main)/chapter/[id].tsx b/app-expo/src/app/(main)/chapter/[id].tsx index 6658520..54493f4 100644 --- a/app-expo/src/app/(main)/chapter/[id].tsx +++ b/app-expo/src/app/(main)/chapter/[id].tsx @@ -1,10 +1,11 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; -import { useLocalSearchParams } from 'expo-router'; -import { Settings, X } from 'lucide-react-native'; +import { router, useLocalSearchParams } from 'expo-router'; +import { Settings, Trash2, X } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; import { ActivityIndicator, + Alert, Modal, Platform, Pressable, @@ -20,7 +21,7 @@ import { Icon } from '@/components/ui/icon'; import { Text } from '@/components/ui/text'; import { ScreenHeader } from '@/components/screen-header'; import { ScreenGutter } from '@/constants/layout'; -import { useChapterDetail } from '@/features/memoir/hooks'; +import { useChapterDetail, useDeleteChapter } from '@/features/memoir/hooks'; // Life-Echo reading colors (from HTML reference) const READING_COLORS = { @@ -536,6 +537,7 @@ export default function ChapterScreen() { const insets = useSafeAreaInsets(); const { t } = useTranslation('memoir'); const { data: chapter, isLoading } = useChapterDetail(id ?? ''); + const deleteChapter = useDeleteChapter(); const [settingsVisible, setSettingsVisible] = useState(false); const [fontSize, setFontSize] = useState('default'); @@ -568,6 +570,7 @@ export default function ChapterScreen() { alignItems: 'center', justifyContent: 'center', backgroundColor: bgColor, + gap: 16, }} > {t('chapterReading.chapterNotFound')} + router.back()} + style={({ pressed }) => ({ + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 12, + backgroundColor: READING_COLORS.primary, + opacity: pressed ? 0.8 : 1, + })} + accessibilityRole="button" + accessibilityLabel={t('chapterReading.back')} + > + + {t('chapterReading.back')} + + ); } @@ -583,6 +605,25 @@ export default function ChapterScreen() { const sections = chapter.sections ?? []; const coverImageUrl = chapter.cover_image?.url ?? null; + const handleDeletePress = () => { + Alert.alert( + t('chapterReading.deleteChapter'), + t('chapterReading.confirmDeleteMessage'), + [ + { text: t('chapterReading.cancel'), style: 'cancel' }, + { + text: t('chapterReading.deleteChapterAction'), + style: 'destructive', + onPress: () => { + deleteChapter.mutate(chapter.id, { + onSuccess: () => router.back(), + }); + }, + }, + ], + ); + }; + return ( setSettingsVisible(true)} - style={({ pressed }) => ({ - padding: 8, - marginRight: -8, - borderRadius: 9999, - opacity: pressed ? 0.7 : 1, - })} - accessibilityLabel={t('chapterReading.settings')} - accessibilityRole="button" - > - - + + ({ + padding: 8, + borderRadius: 9999, + opacity: pressed ? 0.7 : 1, + })} + accessibilityLabel={t('chapterReading.deleteChapter')} + accessibilityRole="button" + > + + + setSettingsVisible(true)} + style={({ pressed }) => ({ + padding: 8, + marginRight: -8, + borderRadius: 9999, + opacity: pressed ? 0.7 : 1, + })} + accessibilityLabel={t('chapterReading.settings')} + accessibilityRole="button" + > + + + } /> diff --git a/app-expo/src/app/(tabs)/index.tsx b/app-expo/src/app/(tabs)/index.tsx index b7e1153..58fc37c 100644 --- a/app-expo/src/app/(tabs)/index.tsx +++ b/app-expo/src/app/(tabs)/index.tsx @@ -3,7 +3,8 @@ import React from 'react'; import { Alert, Pressable, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useTranslation } from 'react-i18next'; -import { CheckCheck, MessageCirclePlus, User } from 'lucide-react-native'; +import { Swipeable } from 'react-native-gesture-handler'; +import { MessageCirclePlus, Trash2, User } from 'lucide-react-native'; import { Icon } from '@/components/ui/icon'; import { Skeleton } from '@/components/ui/skeleton'; @@ -12,6 +13,7 @@ import { NetworkError } from '@/core/api/types'; import { useConversations, useCreateConversation, + useDeleteConversation, } from '@/features/conversation/hooks'; import type { ConversationListItem } from '@/features/conversation/types'; @@ -65,43 +67,13 @@ function GreetingCardSkeleton() { ); } -function StatusBadge({ - status, - label, -}: { - status: 'read' | 'unread'; - label: string; -}) { - const isPrimary = status === 'unread'; - return ( - - {status === 'read' && ( - - )} - {status === 'unread' && ( - - )} - - {label} - - - ); -} - function ConversationCard({ item, - statusLabel, onPress, }: { item: ConversationListItem; - statusLabel: string; onPress: () => void; }) { - const status = item.unreadCount > 0 ? ('unread' as const) : ('read' as const); const avatarBg = item.isDefaultAssistant ? 'bg-primary' : 'bg-secondary'; const avatarIconClass = item.isDefaultAssistant ? 'text-primary-foreground' @@ -136,14 +108,56 @@ function ConversationCard({ > {item.latestMessagePreview || ''} - - - ); } +function SwipeableConversationCard({ + item, + onPress, +}: { + item: ConversationListItem; + onPress: () => void; +}) { + const { t } = useTranslation('conversation'); + const deleteConversation = useDeleteConversation(); + + const handleDelete = () => { + Alert.alert(t('deleteConversation'), t('confirmDeleteConversation'), [ + { text: t('cancel'), style: 'cancel' }, + { + text: t('delete'), + style: 'destructive', + onPress: () => deleteConversation.mutate(item.id), + }, + ]); + }; + + const renderRightActions = () => ( + + + + {t('delete')} + + + ); + + return ( + + + + ); +} + const SKELETON_COUNT = 3; export default function ConversationsScreen() { @@ -277,12 +291,9 @@ export default function ConversationsScreen() { {conversations.map((item) => ( - 0 ? t('unread') : t('read') - } onPress={() => handleConversationPress(item.id)} /> ))} diff --git a/app-expo/src/app/_layout.tsx b/app-expo/src/app/_layout.tsx index 3174aa9..850b113 100644 --- a/app-expo/src/app/_layout.tsx +++ b/app-expo/src/app/_layout.tsx @@ -2,6 +2,7 @@ import { PortalHost } from '@rn-primitives/portal'; import { Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; import React, { useEffect } from 'react'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider, initialWindowMetrics, @@ -43,23 +44,25 @@ export default function RootLayout() { }, [setColorScheme]); return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); } diff --git a/app-expo/src/i18n/generated/resources.ts b/app-expo/src/i18n/generated/resources.ts index c1aed21..cd50f2e 100644 --- a/app-expo/src/i18n/generated/resources.ts +++ b/app-expo/src/i18n/generated/resources.ts @@ -15,7 +15,6 @@ interface Resources { profile: 'Profile'; }; theme: { - brand: 'Brand'; default: 'Default'; }; }; @@ -64,17 +63,22 @@ interface Resources { conversation: { addMore: 'More'; agentName: 'Life Echo'; + cancel: 'Cancel'; cancelRecording: 'Cancel recording'; chatTitle: 'Conversation'; + confirm: 'OK'; + confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.'; connectionConnected: 'Connected'; connectionConnecting: 'Connecting...'; connectionDisconnected: 'Disconnected'; + createError: 'Unable to create conversation. Please check your network and try again.'; + delete: 'Delete'; + deleteConversation: 'Delete Conversation'; emptyGreetingSubtitle: 'Chat with Echo and record your stories.'; greetingTitle: 'Say Hello'; inputPlaceholder: 'Type a message...'; inputPlaceholderVoice: 'Type here or hold the mic to speak...'; me: 'Me'; - read: 'Read'; recentChats: 'Recent Chats'; recordingPermissionDenied: 'Microphone permission is required to record'; send: 'Send'; @@ -83,8 +87,8 @@ interface Resources { switchToVoice: 'Switch to voice input'; tapToEndRecording: 'Tap to end'; tapToStartRecording: 'Tap to start recording'; - unread: 'Unread'; viewAll: 'View All'; + voiceMessagePreview: 'Voice message'; }; explore: {}; home: {}; @@ -95,8 +99,12 @@ interface Resources { backgroundColor: 'Background'; bgPureWhite: 'White'; bgSepia: 'Sepia'; + cancel: 'Cancel'; chapterNotFound: 'Chapter not found'; close: 'Close'; + confirmDeleteMessage: 'Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.'; + deleteChapter: 'Delete Chapter'; + deleteChapterAction: 'Delete'; fontSans: 'Sans'; fontSerif: 'Serif'; fontSize: 'Font Size'; diff --git a/app-expo/src/i18n/locales/en/conversation.json b/app-expo/src/i18n/locales/en/conversation.json index a5f069e..aeb0997 100644 --- a/app-expo/src/i18n/locales/en/conversation.json +++ b/app-expo/src/i18n/locales/en/conversation.json @@ -1,6 +1,10 @@ { + "confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.", "createError": "Unable to create conversation. Please check your network and try again.", "confirm": "OK", + "cancel": "Cancel", + "delete": "Delete", + "deleteConversation": "Delete Conversation", "addMore": "More", "agentName": "Life Echo", "cancelRecording": "Cancel recording", @@ -13,7 +17,6 @@ "inputPlaceholder": "Type a message...", "inputPlaceholderVoice": "Type here or hold the mic to speak...", "me": "Me", - "read": "Read", "recentChats": "Recent Chats", "recordingPermissionDenied": "Microphone permission is required to record", "send": "Send", @@ -22,7 +25,6 @@ "switchToVoice": "Switch to voice input", "tapToEndRecording": "Tap to end", "tapToStartRecording": "Tap to start recording", - "unread": "Unread", "viewAll": "View All", "voiceMessagePreview": "Voice message" } diff --git a/app-expo/src/i18n/locales/en/memoir.json b/app-expo/src/i18n/locales/en/memoir.json index da414a0..d5aadfd 100644 --- a/app-expo/src/i18n/locales/en/memoir.json +++ b/app-expo/src/i18n/locales/en/memoir.json @@ -2,6 +2,10 @@ "chapterLabel": "Chapter {{index}}", "chapterReading": { "back": "Back", + "cancel": "Cancel", + "confirmDeleteMessage": "Are you sure you want to delete this chapter? You will no longer be able to view it, but the content will be kept for future reference.", + "deleteChapter": "Delete Chapter", + "deleteChapterAction": "Delete", "backgroundColor": "Background", "bgPureWhite": "White", "bgSepia": "Sepia", diff --git a/app-expo/src/i18n/locales/zh/conversation.json b/app-expo/src/i18n/locales/zh/conversation.json index 9ba10b9..ed55f15 100644 --- a/app-expo/src/i18n/locales/zh/conversation.json +++ b/app-expo/src/i18n/locales/zh/conversation.json @@ -1,6 +1,10 @@ { + "confirmDeleteConversation": "确定要删除此对话吗?删除后无法恢复。", "createError": "无法创建对话,请检查网络连接或稍后重试", "confirm": "知道了", + "cancel": "取消", + "delete": "删除", + "deleteConversation": "删除对话", "addMore": "更多功能", "agentName": "岁月知己", "cancelRecording": "取消录音发送", @@ -13,7 +17,6 @@ "inputPlaceholder": "输入消息...", "inputPlaceholderVoice": "点击这里输入,或者按住左边说话...", "me": "我", - "read": "已读", "recentChats": "最近对话", "recordingPermissionDenied": "需要麦克风权限才能录音", "send": "发送", @@ -22,7 +25,6 @@ "switchToVoice": "切换到语音输入", "tapToEndRecording": "点击结束", "tapToStartRecording": "点击开始录音", - "unread": "未读", "viewAll": "查看全部", "voiceMessagePreview": "语音消息" } diff --git a/app-expo/src/i18n/locales/zh/memoir.json b/app-expo/src/i18n/locales/zh/memoir.json index 97e603d..2451b51 100644 --- a/app-expo/src/i18n/locales/zh/memoir.json +++ b/app-expo/src/i18n/locales/zh/memoir.json @@ -2,6 +2,10 @@ "chapterLabel": "第 {{index}} 章", "chapterReading": { "back": "返回", + "cancel": "取消", + "confirmDeleteMessage": "确定要删除本章节吗?删除后您将无法再查看,但内容会保留供后续参考。", + "deleteChapter": "删除章节", + "deleteChapterAction": "删除", "backgroundColor": "背景色", "bgPureWhite": "白色", "bgSepia": "护眼",