refactor: extract chat history pane and clarify knowledge ownership
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user