diff --git a/docs/features/document-processing/progress.md b/docs/features/document-processing/progress.md index a2ea27c3..7baee74c 100644 --- a/docs/features/document-processing/progress.md +++ b/docs/features/document-processing/progress.md @@ -50,7 +50,7 @@ reading 与知识库 master-detail 壳层,KnowledgeWorkspace 不再嵌套壳 | 应用中心 → 本机推理监控 | [应用定义](../../../src/renderer/src/ApplicationCenter.tsx)仍为四项;[监控](../../../src/renderer/src/LocalInferencePage.tsx)显示本地服务并加载/释放 OCR | 不承担远程 OCR 配置或管理;关闭监控入口不等于关闭解析能力 | | 监控 → 模型与连接设置 | [IPC](../../../src/main/ipc.ts)的 `localInferenceOpenSettings` 只发出无分类的 `settingsOpen`;[App](../../../src/renderer/src/App.tsx)仅打开设置,[SettingsPanel](../../../src/renderer/src/SettingsPanel.tsx)默认分类为 `runtime` | 文案与默认落点不一致,未直达模型或文档解析;本轮仅记录,产品跳转未修复 | | 聊天附件 | App 的附件按钮直接选文件;当前卡片显示名称、容量、缩略图或图标、文本 tooltip 和移除 | 尚无逐附件更多菜单、显式 OCR、解析结果预览、重解析或文档选图 | -| 知识库文档 | [DocumentsView](../../../src/renderer/src/KnowledgeWorkspace.tsx)提供任务、打开来源、失败文档重试和分块管理;来源行另有同步,库页头另有“用于当前对话” | 解析文档详情、成功文档重解析及选图须新增,不能把分块管理 Modal 当作已有完整解析预览 | +| 知识库文档 | 当时位于 `KnowledgeWorkspace.tsx` 的 DocumentsView 提供任务、打开来源、失败文档重试和分块管理;来源行另有同步,库页头另有“用于当前对话” | 解析文档详情、成功文档重解析及选图须新增,不能把分块管理 Modal 当作已有完整解析预览 | | 新建选图目标会话 | App 的 `startNewConversation` 返回布尔值,拒绝消息通道项目,并在复用空会话或新建后切到聊天 | 预览保留、目标 ID 取得及返回确认仍需接线;直接调用不满足 US-I9 | 本轮更正文档中“既有文档详情”的描述,补齐菜单矩阵及 US-H16 / A-15;队列修改统一先 @@ -58,6 +58,12 @@ reading 与知识库 master-detail 壳层,KnowledgeWorkspace 不再嵌套壳 同步标明本轮范围;本地 OCR 仍为现有能力,HTTP 和新附件流程仍为规划。 未修改产品代码、探索脚本或既有应用排序,OCR、文本及视觉模型调用均为 0。 +2026-09-27 模块归属复核:上述表格保留实施前状态。当前 +[DocumentsView](../../../src/renderer/src/knowledge-workspace/DocumentsView.tsx) +已从工作区入口拆出,提供解析预览、返回列表、重新解析及预览内选图;工作区入口保留库选择、 +检索与分块管理。文档与来源统一列表的行为及验证见 +[知识库进度](../knowledge-base/progress.md#2026-09-27-文档与来源统一列表)。 + ## 2026-09-19 实现与验证 HTTP PaddleOCR-VL 已接入 `DocumentParsingService` 的生产 PDF/PPTX 路径,设置支持来源切换、 diff --git a/docs/features/document-processing/ui-design.md b/docs/features/document-processing/ui-design.md index 35a24e1e..b8ba0262 100644 --- a/docs/features/document-processing/ui-design.md +++ b/docs/features/document-processing/ui-design.md @@ -6,15 +6,15 @@ ## 1. 容器与共享组件 -遵循根目录 [UI 设计系统](../../../UI-DESIGN.md),保留当前应用容器结构。下表依据 -2026-09-18 源码核对,标为新增的内容需要实现,不能当作现有 API。 +遵循根目录 [UI 设计系统](../../../UI-DESIGN.md),保留当前应用容器结构。下表的知识库 +模块归属已于 2026-09-27 复核;各入口的实现与验证状态见[进度](./progress.md)。 | 位置 | 现有结构及设计用法 | | --- | --- | | 设置中心 | `SettingsPanel.tsx` 整窗 Modal 内的 `DocumentParsingSettingsSection.tsx`;继续使用 `SettingsCategoryHeader`、`settings-section`、`field`,不套第二个 PageShell 或一级标题 | | 聊天 | `App.tsx` 的 `PageShell variant="reading"`;附件卡位于原 Composer 内,待发送队列仍在 Composer 外,不在助手工作栏增加附件栏目 | -| 知识库 | `App.tsx` 已提供 `PageShell variant="master-detail"`,`KnowledgeWorkspace.tsx` 内含库导航与 DocumentsView;需在文档区域新增解析详情及返回列表,不再嵌套页面壳层或新建知识库级页签 | -| 解析预览 | 新增共享内容视图,分别嵌入设置诊断 Modal、聊天附件 Modal 和知识库文档详情;共享数据和交互,外层容器负责标题、范围、关闭与滚动 | +| 知识库 | `App.tsx` 提供 `PageShell variant="master-detail"`,`KnowledgeWorkspace.tsx` 保留库导航并挂载 `knowledge-workspace/DocumentsView.tsx`;文档区域内切换解析详情与列表,不嵌套页面壳层或新建知识库级页签 | +| 解析预览 | `DocumentResultPreview.tsx` 分别嵌入设置诊断 Modal、聊天附件 Modal 和知识库文档详情;共享数据和交互,外层容器负责标题、范围、关闭与滚动 | | 页签与选择 | `WorkspacePrimitives.tsx` 的 `PageTabs variant="segmented"` 承载正文、图片、详情;来源及过滤模式使用 `SegmentedControl`,不能用它替换页签语义 | | 浮层 | Modal 沿用 body Portal、`activateModalFocus` 与 `trapTabFocus`;测试及附件菜单复用 `AnchoredMenu`,由共享 `FloatingPortal` 挂入所属 Modal 并在 top layer 绘制。通知遵循根 UI 规范 | | 图文内容 | 复用 `MarkdownRenderer.tsx` 的排版、表格滚动和静态 HTML 处理边界;补齐受管图片与 OCR HTML 表格适配,现有 Markdown 渲染器并未直接支持这些资源 | @@ -36,8 +36,8 @@ | 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;全局模型下载源仍在“平台功能 / 通用设置”,与 OCR 来源选择分开 | | 查看或释放本地 OCR 内存 | “应用中心” → “本机推理监控”(`local-inference` Modal),或其常驻入口 | 只管理本机执行服务;应用启用及常驻控制入口,不控制解析功能;HTTP 服务不出现在本机服务列表,也不提供远程启停 | | 添加文件或图片 | “当前对话” → “添加附件”,或输入框粘贴 | 先加入当前草稿,再按输入类型处理;上传按钮继续直接选文件,不改成先选 OCR 的菜单 | -| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 规划中的共享预览;来源消息与目标草稿分别显示,历史输入只读 | -| 查看知识文档结果 | “知识库” → 选库 → “文档与来源”中的文档操作 | 规划新增“查看解析结果”和文档详情;返回恢复库、列表筛选和位置;不改变活动会话 | +| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 打开共享预览;来源消息与目标草稿分别显示,历史输入只读 | +| 查看知识文档结果 | “知识库” → 选库 → “文档与来源”中的文档操作 | “查看解析结果”进入文档详情;返回恢复库、列表筛选和位置;不改变活动会话 | | 把文档插图用于聊天 | 聊天或知识文档预览 → “图片” → “添加所选图片” | 明确目标会话草稿后复制;设置测试预览无此操作;已有“用于当前对话”仍负责知识库检索范围,不代替选图 | 从解析失败或未配置提示提供“前往文档解析设置”时,须直达 `document-parsing` 分类并保留 diff --git a/docs/features/knowledge-base/external-knowledge-technical-design.md b/docs/features/knowledge-base/external-knowledge-technical-design.md index d4e41f4e..0261d0ce 100644 --- a/docs/features/knowledge-base/external-knowledge-technical-design.md +++ b/docs/features/knowledge-base/external-knowledge-technical-design.md @@ -37,6 +37,24 @@ | `src/renderer/src/ExternalKnowledge.tsx` | 实例 Modal、绑定表单、Provider 配置、外部详情与检索结果 | | `KnowledgeWorkspace.tsx`、`App.tsx`、`KnowledgeCitationDialog.tsx` | 统一列表、聊天选择、刷新及历史引用显示 | +Renderer 的 `KnowledgeWorkspace.tsx` 保留库选择、页签路由、检索与分块管理状态及回调。 +原有子组件位于 `src/renderer/src/knowledge-workspace/`: + +| 文件 | 职责 | +| --- | --- | +| `DocumentsView.tsx` | 来源导入、文档列表与解析结果预览 | +| `KnowledgeSettingsView.tsx` | 索引、分块及图谱配置,向量索引状态轮询 | +| `KnowledgeTasksView.tsx` | 任务筛选、层级展示、取消和重试 | +| `GraphView.tsx`、`GraphEditors.tsx` | 图谱浏览、实体与关系编辑 | +| `KnowledgeGraphChartLoader.tsx` | 图谱画布延迟加载及失败重试 | +| `LibraryDialogs.tsx`、`RemoveSourceDialog.tsx`、`GraphDestructiveDialog.tsx` | 建库表单、库编辑与删除、来源移除及图谱破坏性操作确认 | +| `types.ts` | 工作区 Props 与组件间使用的类型 | +| `formatting.ts`、`helpers.ts` | 多个组件共用的本地化格式化、标签、别名解析、进度和错误处理 | + +子组件直接导入所需模块,不反向依赖工作区入口。单个视图专用的格式化与标签留在该视图, +检索结果转换留在入口。模块拆分保留组件 Props、挂载位置、key 与副作用顺序;文档与来源 +合并后的列表结构及操作归属见 [UI 设计](./ui-design.md#6-来源操作)。 + `ExternalKnowledge.tsx` 的字段改用全局 `.field`,修正 body Portal 脱离知识库祖先后 丢失 scoped 控件样式的问题。CSS 提供 560px 紧凑弹窗和粘附底部操作区;实例选择复用 既有 picker 样式并实现菜单键盘行为。没有新增 Main 模块,具体控件规则由 diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 6c967cc5..c0d19c43 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -26,7 +26,6 @@ import { } from "../../shared/application-settings-contracts"; import type { ImageOperation } from "../../shared/image-generation-contracts"; import { - ArrowDown, Bot, ChartColumn, Check, @@ -168,13 +167,18 @@ import { normalizeInteractiveWorkMode, projectChannelLabels, } from "../../shared/assistant-contracts"; -import { - ChatTimeline, - type ImageViewerItem, - type Message, - type SubagentActivity, - type ToolActivity, +import type { + ImageViewerItem, + Message, + SubagentActivity, + ToolActivity, } from "./ChatTimeline"; +import { + ChatHistoryPane, + messageRenderBatchSize, + type ChatScrollSnapshot, +} from "./ChatHistoryPane"; +import { isUnusedConversation, type Conversation } from "./chat-conversation"; import { clearLegacyActivityHistory, loadLegacyActivityHistory, @@ -246,10 +250,7 @@ import type { RemoteProjectRecoveryState } from "../../shared/remote-project-rec import { ReleaseNotesDialog } from "./ReleaseNotesDialog"; import { scheduleIdleRoutePreload } from "./idle-route-preload"; import { createPreloadableComponent } from "./preloadable-component"; -import { - formatConversationListTime, - type TimeFormatLocale, -} from "./time-format"; +import { formatConversationListTime } from "./time-format"; import { formatMediumDateTime } from "./locale-formatters"; import { formatCompactTokens } from "./token-format"; import { @@ -294,7 +295,6 @@ const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component; const SettingsPanel = settingsPanelRoute.Component; const ActivityPanel = activityPanelRoute.Component; -const messageRenderBatchSize = 80; const conversationPersistenceIntervalMs = 500; const conversationSearchSnapshotDelayMs = 250; const keepAliveExpirationMs = 60 * 60 * 1_000; @@ -527,10 +527,6 @@ function supportsSubagentSmartRouting(workMode: string): boolean { return workMode === "ask"; } -type Conversation = Omit & { - messages: Message[]; -}; - type ActiveRun = { conversationId: string; messageId: string; @@ -757,8 +753,6 @@ function isErrorRepresentedByFailedTool( ); } -const chatBottomProximity = 96; - function createConversation( projectId?: string, runtimeSelection?: AgentRuntimeSelection, @@ -816,14 +810,6 @@ function ConversationBranchBadge({ ); } -function isUnusedConversation(conversation: Conversation): boolean { - return ( - conversation.title === "新对话" && - (conversation.messageSummary?.count ?? conversation.messages.length) === 1 && - (conversation.messageSummary?.firstRole ?? conversation.messages[0]?.role) === "assistant" - ); -} - function getConversationDisplayTitle( conversation: Conversation, defaultTitle: string, @@ -831,17 +817,6 @@ function getConversationDisplayTitle( return isUnusedConversation(conversation) ? defaultTitle : conversation.title; } -type ChatQuickAction = { - title: string; - description: string; - prompt: string; -}; - -type ChatScrollSnapshot = { - pinnedToBottom: boolean; - scrollTop: number; -}; - function ConversationHistoryLoader({ conversationId, active, load }: { conversationId: string; active: boolean; @@ -866,379 +841,6 @@ function ConversationHistoryLoader({ conversationId, active, load }: { ; } -function ChatHistoryPane({ - onAddToNote, - noteMessageNavigation, - onOpenImageModelSettings, - onReselectImageSources, - onEditImage, - active, - artifactById, - conversationHtmlRenderingEnabled, - conversation, - locale, - onCopyMessage, - onDownloadImage, - onOpenCitationContext, - onOpenCitationSource, - onOpenImage, - onRespondApproval, - onRespondQuestion, - onRetry, - onScrollSnapshotChange, - onSetInput, - onVisibleMessageCountChange, - quickActions, - scrollSnapshot, - taskStrip, - visibleMessageCount, -}: { - onAddToNote?: (conversationId: string, message: Message, trigger: HTMLElement) => void; - noteMessageNavigation?: { conversationId: string; messageId: string; requestId: number }; - onOpenImageModelSettings: () => void; - onReselectImageSources: (operation: ImageOperation) => void; - onEditImage: (artifact: AssistantArtifact) => void; - active: boolean; - artifactById: ReadonlyMap; - conversationHtmlRenderingEnabled: boolean; - conversation: Conversation; - locale: TimeFormatLocale; - onCopyMessage: (content: string, kind?: 'tool') => Promise; - onDownloadImage: (item: ImageViewerItem) => void; - onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise; - onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise; - onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void; - onRespondApproval: ( - conversationId: string, - messageId: string, - approvalId: string, - decision: ApprovalDecision, - ) => Promise; - onRespondQuestion: ( - conversationId: string, - messageId: string, - questionId: string, - answers?: AgentQuestionAnswer[], - ) => Promise; - onRetry: (content: string) => void; - onScrollSnapshotChange: ( - conversationId: string, - snapshot: ChatScrollSnapshot, - ) => void; - onSetInput: (value: string) => void; - onVisibleMessageCountChange: (conversationId: string, count: number) => void; - quickActions: ChatQuickAction[]; - scrollSnapshot?: ChatScrollSnapshot; - taskStrip?: ReactNode; - visibleMessageCount: number; -}): React.JSX.Element { - const { t } = useTranslation("app"); - const headingId = `chat-heading-${conversation.id}`; - const scrollRef = useRef(null); - const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true); - const latestScrollSnapshotRef = useRef(scrollSnapshot); - const restorePendingRef = useRef(true); - const prependScrollPositionRef = useRef< - | { - scrollHeight: number; - scrollTop: number; - } - | undefined - >(undefined); - const finalRevealedMessageIdRef = useRef(undefined); - const messageArticleRefs = useRef(new Map()); - const previousMessageCountRef = useRef(conversation.messages.length); - const [showScrollToBottom, setShowScrollToBottom] = useState( - scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false, - ); - const visibleMessageStartIndex = Math.max( - 0, - conversation.messages.length - visibleMessageCount, - ); - const visibleMessages = useMemo( - () => conversation.messages.slice(visibleMessageStartIndex), - [conversation.messages, visibleMessageStartIndex], - ); - const hiddenMessageCount = visibleMessageStartIndex; - - useEffect(() => { - if (!active || noteMessageNavigation?.conversationId !== conversation.id) return; - const element = messageArticleRefs.current.get(noteMessageNavigation.messageId); - if (!element) return; - const frame = requestAnimationFrame(() => { - element.scrollIntoView?.({ block: 'center' }); - element.tabIndex = -1; - element.focus({ preventScroll: true }); - }); - return () => cancelAnimationFrame(frame); - }, [active, conversation.id, noteMessageNavigation, visibleMessageCount]); - - const handleArticleRef = useCallback( - (messageId: string, element: HTMLElement | null): void => { - if (element) { - messageArticleRefs.current.set(messageId, element); - } else { - messageArticleRefs.current.delete(messageId); - } - }, - [], - ); - - const saveScrollPosition = useCallback( - (scrollContainer: HTMLElement): boolean => { - const distanceFromBottom = - scrollContainer.scrollHeight - - scrollContainer.scrollTop - - scrollContainer.clientHeight; - const pinnedToBottom = distanceFromBottom <= chatBottomProximity; - latestScrollSnapshotRef.current = { - pinnedToBottom, - scrollTop: scrollContainer.scrollTop, - }; - return pinnedToBottom; - }, - [], - ); - - const handleScrollRef = useCallback( - (element: HTMLElement | null): void => { - const previous = scrollRef.current; - if (previous && previous !== element) { - saveScrollPosition(previous); - if (!element && latestScrollSnapshotRef.current) { - onScrollSnapshotChange( - conversation.id, - latestScrollSnapshotRef.current, - ); - } - } - scrollRef.current = element; - }, - [conversation.id, onScrollSnapshotChange, saveScrollPosition], - ); - - const updateScrollPosition = useCallback((): void => { - const scrollContainer = scrollRef.current; - if (!scrollContainer) { - return; - } - const atBottom = saveScrollPosition(scrollContainer); - pinnedToBottomRef.current = atBottom; - setShowScrollToBottom(!atBottom); - }, [saveScrollPosition]); - - useLayoutEffect(() => { - const previousMessageCount = previousMessageCountRef.current; - if ( - conversation.messages - .slice(previousMessageCount) - .some((message) => message.role === "user") - ) { - pinnedToBottomRef.current = true; - } - previousMessageCountRef.current = conversation.messages.length; - }, [conversation.messages]); - - useLayoutEffect(() => { - if (!active) { - return; - } - const scrollContainer = scrollRef.current; - if (!scrollContainer) { - return; - } - if (restorePendingRef.current) { - restorePendingRef.current = false; - if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) { - pinnedToBottomRef.current = false; - scrollContainer.scrollTop = scrollSnapshot.scrollTop; - return; - } - } - if (pinnedToBottomRef.current) { - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: "auto", - }); - } - }, [active, conversation.messages, scrollSnapshot, visibleMessageCount]); - - useLayoutEffect(() => { - if (!active || typeof ResizeObserver !== "function") { - return; - } - const scrollContainer = scrollRef.current; - const messageList = - scrollContainer?.querySelector(".message-list"); - if (!scrollContainer || !messageList) { - return; - } - const observer = new ResizeObserver(() => { - if (restorePendingRef.current || !pinnedToBottomRef.current) { - return; - } - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: "auto", - }); - }); - observer.observe(messageList); - return () => observer.disconnect(); - }, [active]); - - useLayoutEffect(() => { - const previous = prependScrollPositionRef.current; - if (!previous) { - return; - } - prependScrollPositionRef.current = undefined; - const scrollContainer = scrollRef.current; - if (!scrollContainer) { - return; - } - scrollContainer.scrollTop = - previous.scrollTop + - (scrollContainer.scrollHeight - previous.scrollHeight); - const finalRevealedMessageId = finalRevealedMessageIdRef.current; - finalRevealedMessageIdRef.current = undefined; - if (finalRevealedMessageId) { - messageArticleRefs.current - .get(finalRevealedMessageId) - ?.focus({ preventScroll: true }); - } - }, [visibleMessageCount]); - - const revealEarlierMessages = useCallback((): void => { - const scrollContainer = scrollRef.current; - if (scrollContainer) { - prependScrollPositionRef.current = { - scrollHeight: scrollContainer.scrollHeight, - scrollTop: scrollContainer.scrollTop, - }; - } - if ( - visibleMessageCount + messageRenderBatchSize >= - conversation.messages.length - ) { - finalRevealedMessageIdRef.current = conversation.messages[0]?.id; - } - onVisibleMessageCountChange( - conversation.id, - visibleMessageCount + messageRenderBatchSize, - ); - }, [ - conversation.id, - conversation.messages, - onVisibleMessageCountChange, - visibleMessageCount, - ]); - - const scrollToBottom = (): void => { - const scrollContainer = scrollRef.current; - if (!scrollContainer) { - return; - } - pinnedToBottomRef.current = true; - setShowScrollToBottom(false); - const reduceMotion = - typeof window.matchMedia === "function" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; - scrollContainer.scrollTo({ - top: scrollContainer.scrollHeight, - behavior: reduceMotion ? "auto" : "smooth", - }); - }; - - return ( - - ); -} - function isConversationAttachment( value: unknown, ): value is ConversationAttachment { diff --git a/src/renderer/src/ChatHistoryPane.tsx b/src/renderer/src/ChatHistoryPane.tsx new file mode 100644 index 00000000..c2b16701 --- /dev/null +++ b/src/renderer/src/ChatHistoryPane.tsx @@ -0,0 +1,408 @@ +import { ArrowDown, FileText, Sparkles } from "lucide-react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { useTranslation } from "react-i18next"; +import type { AssistantArtifact } from "../../shared/assistant-contracts"; +import type { + AgentQuestionAnswer, + ApprovalDecision, + KnowledgeSearchReference, +} from "../../shared/contracts"; +import type { ImageOperation } from "../../shared/image-generation-contracts"; +import { ChatTimeline, type ImageViewerItem, type Message } from "./ChatTimeline"; +import { isUnusedConversation, type Conversation } from "./chat-conversation"; +import type { TimeFormatLocale } from "./time-format"; + +export const messageRenderBatchSize = 80; +const chatBottomProximity = 96; + +type ChatQuickAction = { + title: string; + description: string; + prompt: string; +}; + +export type ChatScrollSnapshot = { + pinnedToBottom: boolean; + scrollTop: number; +}; + +export function ChatHistoryPane({ + onAddToNote, + noteMessageNavigation, + onOpenImageModelSettings, + onReselectImageSources, + onEditImage, + active, + artifactById, + conversationHtmlRenderingEnabled, + conversation, + locale, + onCopyMessage, + onDownloadImage, + onOpenCitationContext, + onOpenCitationSource, + onOpenImage, + onRespondApproval, + onRespondQuestion, + onRetry, + onScrollSnapshotChange, + onSetInput, + onVisibleMessageCountChange, + quickActions, + scrollSnapshot, + taskStrip, + visibleMessageCount, +}: { + onAddToNote?: (conversationId: string, message: Message, trigger: HTMLElement) => void; + noteMessageNavigation?: { conversationId: string; messageId: string; requestId: number }; + onOpenImageModelSettings: () => void; + onReselectImageSources: (operation: ImageOperation) => void; + onEditImage: (artifact: AssistantArtifact) => void; + active: boolean; + artifactById: ReadonlyMap; + conversationHtmlRenderingEnabled: boolean; + conversation: Conversation; + locale: TimeFormatLocale; + onCopyMessage: (content: string, kind?: 'tool') => Promise; + onDownloadImage: (item: ImageViewerItem) => void; + onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise; + onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise; + onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void; + onRespondApproval: ( + conversationId: string, + messageId: string, + approvalId: string, + decision: ApprovalDecision, + ) => Promise; + onRespondQuestion: ( + conversationId: string, + messageId: string, + questionId: string, + answers?: AgentQuestionAnswer[], + ) => Promise; + onRetry: (content: string) => void; + onScrollSnapshotChange: ( + conversationId: string, + snapshot: ChatScrollSnapshot, + ) => void; + onSetInput: (value: string) => void; + onVisibleMessageCountChange: (conversationId: string, count: number) => void; + quickActions: ChatQuickAction[]; + scrollSnapshot?: ChatScrollSnapshot; + taskStrip?: ReactNode; + visibleMessageCount: number; +}): React.JSX.Element { + const { t } = useTranslation("app"); + const headingId = `chat-heading-${conversation.id}`; + const scrollRef = useRef(null); + const pinnedToBottomRef = useRef(scrollSnapshot?.pinnedToBottom ?? true); + const latestScrollSnapshotRef = useRef(scrollSnapshot); + const restorePendingRef = useRef(true); + const prependScrollPositionRef = useRef< + | { + scrollHeight: number; + scrollTop: number; + } + | undefined + >(undefined); + const finalRevealedMessageIdRef = useRef(undefined); + const messageArticleRefs = useRef(new Map()); + const previousMessageCountRef = useRef(conversation.messages.length); + const [showScrollToBottom, setShowScrollToBottom] = useState( + scrollSnapshot ? !scrollSnapshot.pinnedToBottom : false, + ); + const visibleMessageStartIndex = Math.max( + 0, + conversation.messages.length - visibleMessageCount, + ); + const visibleMessages = useMemo( + () => conversation.messages.slice(visibleMessageStartIndex), + [conversation.messages, visibleMessageStartIndex], + ); + const hiddenMessageCount = visibleMessageStartIndex; + + useEffect(() => { + if (!active || noteMessageNavigation?.conversationId !== conversation.id) return; + const element = messageArticleRefs.current.get(noteMessageNavigation.messageId); + if (!element) return; + const frame = requestAnimationFrame(() => { + element.scrollIntoView?.({ block: 'center' }); + element.tabIndex = -1; + element.focus({ preventScroll: true }); + }); + return () => cancelAnimationFrame(frame); + }, [active, conversation.id, noteMessageNavigation, visibleMessageCount]); + + const handleArticleRef = useCallback( + (messageId: string, element: HTMLElement | null): void => { + if (element) { + messageArticleRefs.current.set(messageId, element); + } else { + messageArticleRefs.current.delete(messageId); + } + }, + [], + ); + + const saveScrollPosition = useCallback( + (scrollContainer: HTMLElement): boolean => { + const distanceFromBottom = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + const pinnedToBottom = distanceFromBottom <= chatBottomProximity; + latestScrollSnapshotRef.current = { + pinnedToBottom, + scrollTop: scrollContainer.scrollTop, + }; + return pinnedToBottom; + }, + [], + ); + + const handleScrollRef = useCallback( + (element: HTMLElement | null): void => { + const previous = scrollRef.current; + if (previous && previous !== element) { + saveScrollPosition(previous); + if (!element && latestScrollSnapshotRef.current) { + onScrollSnapshotChange( + conversation.id, + latestScrollSnapshotRef.current, + ); + } + } + scrollRef.current = element; + }, + [conversation.id, onScrollSnapshotChange, saveScrollPosition], + ); + + const updateScrollPosition = useCallback((): void => { + const scrollContainer = scrollRef.current; + if (!scrollContainer) { + return; + } + const atBottom = saveScrollPosition(scrollContainer); + pinnedToBottomRef.current = atBottom; + setShowScrollToBottom(!atBottom); + }, [saveScrollPosition]); + + useLayoutEffect(() => { + const previousMessageCount = previousMessageCountRef.current; + if ( + conversation.messages + .slice(previousMessageCount) + .some((message) => message.role === "user") + ) { + pinnedToBottomRef.current = true; + } + previousMessageCountRef.current = conversation.messages.length; + }, [conversation.messages]); + + useLayoutEffect(() => { + if (!active) { + return; + } + const scrollContainer = scrollRef.current; + if (!scrollContainer) { + return; + } + if (restorePendingRef.current) { + restorePendingRef.current = false; + if (scrollSnapshot && !scrollSnapshot.pinnedToBottom) { + pinnedToBottomRef.current = false; + scrollContainer.scrollTop = scrollSnapshot.scrollTop; + return; + } + } + if (pinnedToBottomRef.current) { + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: "auto", + }); + } + }, [active, conversation.messages, scrollSnapshot, visibleMessageCount]); + + useLayoutEffect(() => { + if (!active || typeof ResizeObserver !== "function") { + return; + } + const scrollContainer = scrollRef.current; + const messageList = + scrollContainer?.querySelector(".message-list"); + if (!scrollContainer || !messageList) { + return; + } + const observer = new ResizeObserver(() => { + if (restorePendingRef.current || !pinnedToBottomRef.current) { + return; + } + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: "auto", + }); + }); + observer.observe(messageList); + return () => observer.disconnect(); + }, [active]); + + useLayoutEffect(() => { + const previous = prependScrollPositionRef.current; + if (!previous) { + return; + } + prependScrollPositionRef.current = undefined; + const scrollContainer = scrollRef.current; + if (!scrollContainer) { + return; + } + scrollContainer.scrollTop = + previous.scrollTop + + (scrollContainer.scrollHeight - previous.scrollHeight); + const finalRevealedMessageId = finalRevealedMessageIdRef.current; + finalRevealedMessageIdRef.current = undefined; + if (finalRevealedMessageId) { + messageArticleRefs.current + .get(finalRevealedMessageId) + ?.focus({ preventScroll: true }); + } + }, [visibleMessageCount]); + + const revealEarlierMessages = useCallback((): void => { + const scrollContainer = scrollRef.current; + if (scrollContainer) { + prependScrollPositionRef.current = { + scrollHeight: scrollContainer.scrollHeight, + scrollTop: scrollContainer.scrollTop, + }; + } + if ( + visibleMessageCount + messageRenderBatchSize >= + conversation.messages.length + ) { + finalRevealedMessageIdRef.current = conversation.messages[0]?.id; + } + onVisibleMessageCountChange( + conversation.id, + visibleMessageCount + messageRenderBatchSize, + ); + }, [ + conversation.id, + conversation.messages, + onVisibleMessageCountChange, + visibleMessageCount, + ]); + + const scrollToBottom = (): void => { + const scrollContainer = scrollRef.current; + if (!scrollContainer) { + return; + } + pinnedToBottomRef.current = true; + setShowScrollToBottom(false); + const reduceMotion = + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + scrollContainer.scrollTo({ + top: scrollContainer.scrollHeight, + behavior: reduceMotion ? "auto" : "smooth", + }); + }; + + return ( + + ); +} diff --git a/src/renderer/src/chat-conversation.ts b/src/renderer/src/chat-conversation.ts new file mode 100644 index 00000000..6aae6e0c --- /dev/null +++ b/src/renderer/src/chat-conversation.ts @@ -0,0 +1,14 @@ +import type { ConversationListSnapshot } from "../../shared/assistant-contracts"; +import type { Message } from "./ChatTimeline"; + +export type Conversation = Omit & { + messages: Message[]; +}; + +export function isUnusedConversation(conversation: Conversation): boolean { + return ( + conversation.title === "新对话" && + (conversation.messageSummary?.count ?? conversation.messages.length) === 1 && + (conversation.messageSummary?.firstRole ?? conversation.messages[0]?.role) === "assistant" + ); +}