refactor: extract chat history pane and clarify knowledge ownership

This commit is contained in:
mesalogo
2026-09-27 10:12:10 +08:00
parent 40c108da1a
commit 44cd944601
6 changed files with 465 additions and 417 deletions
@@ -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 路径,设置支持来源切换、
@@ -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` 分类并保留
@@ -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 模块,具体控件规则由
+12 -410
View File
@@ -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<ConversationListSnapshot, "messages"> & {
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 }: {
</section>;
}
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<string, AssistantArtifact>;
conversationHtmlRenderingEnabled: boolean;
conversation: Conversation;
locale: TimeFormatLocale;
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
onDownloadImage: (item: ImageViewerItem) => void;
onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise<void>;
onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise<void>;
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void;
onRespondApproval: (
conversationId: string,
messageId: string,
approvalId: string,
decision: ApprovalDecision,
) => Promise<void>;
onRespondQuestion: (
conversationId: string,
messageId: string,
questionId: string,
answers?: AgentQuestionAnswer[],
) => Promise<void>;
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<HTMLElement>(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<string | undefined>(undefined);
const messageArticleRefs = useRef(new Map<string, HTMLElement>());
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<HTMLElement>(".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 (
<div
aria-hidden={active ? undefined : "true"}
className="chat-history-pane"
data-active={active ? "true" : "false"}
data-conversation-id={conversation.id}
hidden={!active}
inert={!active}
>
<h1 className="sr-only" id={headingId}>
{t("chat.heading")}
</h1>
{taskStrip}
<section
aria-labelledby={headingId}
className="chat"
id={active ? "chat-message-list" : undefined}
onScroll={updateScrollPosition}
ref={handleScrollRef}
>
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
</section>
{active && showScrollToBottom && (
<button
aria-controls="chat-message-list"
aria-label={t("chat.scrollToBottom")}
className="chat-scroll-to-bottom"
onClick={scrollToBottom}
title={t("chat.scrollToBottom")}
type="button"
>
<ArrowDown aria-hidden="true" size={18} />
</button>
)}
</div>
);
}
function isConversationAttachment(
value: unknown,
): value is ConversationAttachment {
+408
View File
@@ -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<string, AssistantArtifact>;
conversationHtmlRenderingEnabled: boolean;
conversation: Conversation;
locale: TimeFormatLocale;
onCopyMessage: (content: string, kind?: 'tool') => Promise<boolean>;
onDownloadImage: (item: ImageViewerItem) => void;
onOpenCitationContext: (reference: KnowledgeSearchReference) => Promise<void>;
onOpenCitationSource: (reference: KnowledgeSearchReference) => Promise<void>;
onOpenImage: (item: ImageViewerItem, trigger: HTMLElement) => void;
onRespondApproval: (
conversationId: string,
messageId: string,
approvalId: string,
decision: ApprovalDecision,
) => Promise<void>;
onRespondQuestion: (
conversationId: string,
messageId: string,
questionId: string,
answers?: AgentQuestionAnswer[],
) => Promise<void>;
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<HTMLElement>(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<string | undefined>(undefined);
const messageArticleRefs = useRef(new Map<string, HTMLElement>());
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<HTMLElement>(".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 (
<div
aria-hidden={active ? undefined : "true"}
className="chat-history-pane"
data-active={active ? "true" : "false"}
data-conversation-id={conversation.id}
hidden={!active}
inert={!active}
>
<h1 className="sr-only" id={headingId}>
{t("chat.heading")}
</h1>
{taskStrip}
<section
aria-labelledby={headingId}
className="chat"
id={active ? "chat-message-list" : undefined}
onScroll={updateScrollPosition}
ref={handleScrollRef}
>
{isUnusedConversation(conversation) && (
<div className="welcome">
<div className="welcome__badge">
<Sparkles size={18} />
</div>
<h2>{t("chat.welcome.title")}</h2>
<div className="quick-actions">
{quickActions.map((action) => (
<button
key={action.title}
onClick={() => onSetInput(action.prompt)}
type="button"
>
<span className="quick-actions__icon">
<FileText size={17} />
</span>
<strong>{action.title}</strong>
<small>{action.description}</small>
</button>
))}
</div>
</div>
)}
<ChatTimeline
onOpenImageModelSettings={onOpenImageModelSettings}
onReselectImageSources={onReselectImageSources}
onEditImage={onEditImage}
artifactById={artifactById}
conversationId={conversation.id}
hiddenMessageCount={hiddenMessageCount}
isUnusedConversation={isUnusedConversation(conversation)}
locale={locale}
messages={visibleMessages}
messageStartIndex={visibleMessageStartIndex}
onArticleRef={handleArticleRef}
onCopyMessage={onCopyMessage}
onAddToNote={onAddToNote}
onDownloadImage={onDownloadImage}
onOpenCitationContext={onOpenCitationContext}
onOpenCitationSource={onOpenCitationSource}
onOpenImage={onOpenImage}
onRespondApproval={onRespondApproval}
onRespondQuestion={onRespondQuestion}
onRetry={onRetry}
onRevealEarlier={revealEarlierMessages}
renderAssistantHtml={conversationHtmlRenderingEnabled}
retryContent={
conversation.messages.at(-2)?.role === "user"
? conversation.messages.at(-2)?.content
: undefined
}
totalMessageCount={conversation.messages.length}
/>
</section>
{active && showScrollToBottom && (
<button
aria-controls="chat-message-list"
aria-label={t("chat.scrollToBottom")}
className="chat-scroll-to-bottom"
onClick={scrollToBottom}
title={t("chat.scrollToBottom")}
type="button"
>
<ArrowDown aria-hidden="true" size={18} />
</button>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import type { ConversationListSnapshot } from "../../shared/assistant-contracts";
import type { Message } from "./ChatTimeline";
export type Conversation = Omit<ConversationListSnapshot, "messages"> & {
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"
);
}