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 配置或管理;关闭监控入口不等于关闭解析能力 |
|
| 应用中心 → 本机推理监控 | [应用定义](../../../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` | 文案与默认落点不一致,未直达模型或文档解析;本轮仅记录,产品跳转未修复 |
|
| 监控 → 模型与连接设置 | [IPC](../../../src/main/ipc.ts)的 `localInferenceOpenSettings` 只发出无分类的 `settingsOpen`;[App](../../../src/renderer/src/App.tsx)仅打开设置,[SettingsPanel](../../../src/renderer/src/SettingsPanel.tsx)默认分类为 `runtime` | 文案与默认落点不一致,未直达模型或文档解析;本轮仅记录,产品跳转未修复 |
|
||||||
| 聊天附件 | App 的附件按钮直接选文件;当前卡片显示名称、容量、缩略图或图标、文本 tooltip 和移除 | 尚无逐附件更多菜单、显式 OCR、解析结果预览、重解析或文档选图 |
|
| 聊天附件 | App 的附件按钮直接选文件;当前卡片显示名称、容量、缩略图或图标、文本 tooltip 和移除 | 尚无逐附件更多菜单、显式 OCR、解析结果预览、重解析或文档选图 |
|
||||||
| 知识库文档 | [DocumentsView](../../../src/renderer/src/KnowledgeWorkspace.tsx)提供任务、打开来源、失败文档重试和分块管理;来源行另有同步,库页头另有“用于当前对话” | 解析文档详情、成功文档重解析及选图须新增,不能把分块管理 Modal 当作已有完整解析预览 |
|
| 知识库文档 | 当时位于 `KnowledgeWorkspace.tsx` 的 DocumentsView 提供任务、打开来源、失败文档重试和分块管理;来源行另有同步,库页头另有“用于当前对话” | 解析文档详情、成功文档重解析及选图须新增,不能把分块管理 Modal 当作已有完整解析预览 |
|
||||||
| 新建选图目标会话 | App 的 `startNewConversation` 返回布尔值,拒绝消息通道项目,并在复用空会话或新建后切到聊天 | 预览保留、目标 ID 取得及返回确认仍需接线;直接调用不满足 US-I9 |
|
| 新建选图目标会话 | App 的 `startNewConversation` 返回布尔值,拒绝消息通道项目,并在复用空会话或新建后切到聊天 | 预览保留、目标 ID 取得及返回确认仍需接线;直接调用不满足 US-I9 |
|
||||||
|
|
||||||
本轮更正文档中“既有文档详情”的描述,补齐菜单矩阵及 US-H16 / A-15;队列修改统一先
|
本轮更正文档中“既有文档详情”的描述,补齐菜单矩阵及 US-H16 / A-15;队列修改统一先
|
||||||
@@ -58,6 +58,12 @@ reading 与知识库 master-detail 壳层,KnowledgeWorkspace 不再嵌套壳
|
|||||||
同步标明本轮范围;本地 OCR 仍为现有能力,HTTP 和新附件流程仍为规划。
|
同步标明本轮范围;本地 OCR 仍为现有能力,HTTP 和新附件流程仍为规划。
|
||||||
未修改产品代码、探索脚本或既有应用排序,OCR、文本及视觉模型调用均为 0。
|
未修改产品代码、探索脚本或既有应用排序,OCR、文本及视觉模型调用均为 0。
|
||||||
|
|
||||||
|
2026-09-27 模块归属复核:上述表格保留实施前状态。当前
|
||||||
|
[DocumentsView](../../../src/renderer/src/knowledge-workspace/DocumentsView.tsx)
|
||||||
|
已从工作区入口拆出,提供解析预览、返回列表、重新解析及预览内选图;工作区入口保留库选择、
|
||||||
|
检索与分块管理。文档与来源统一列表的行为及验证见
|
||||||
|
[知识库进度](../knowledge-base/progress.md#2026-09-27-文档与来源统一列表)。
|
||||||
|
|
||||||
## 2026-09-19 实现与验证
|
## 2026-09-19 实现与验证
|
||||||
|
|
||||||
HTTP PaddleOCR-VL 已接入 `DocumentParsingService` 的生产 PDF/PPTX 路径,设置支持来源切换、
|
HTTP PaddleOCR-VL 已接入 `DocumentParsingService` 的生产 PDF/PPTX 路径,设置支持来源切换、
|
||||||
|
|||||||
@@ -6,15 +6,15 @@
|
|||||||
|
|
||||||
## 1. 容器与共享组件
|
## 1. 容器与共享组件
|
||||||
|
|
||||||
遵循根目录 [UI 设计系统](../../../UI-DESIGN.md),保留当前应用容器结构。下表依据
|
遵循根目录 [UI 设计系统](../../../UI-DESIGN.md),保留当前应用容器结构。下表的知识库
|
||||||
2026-09-18 源码核对,标为新增的内容需要实现,不能当作现有 API。
|
模块归属已于 2026-09-27 复核;各入口的实现与验证状态见[进度](./progress.md)。
|
||||||
|
|
||||||
| 位置 | 现有结构及设计用法 |
|
| 位置 | 现有结构及设计用法 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 设置中心 | `SettingsPanel.tsx` 整窗 Modal 内的 `DocumentParsingSettingsSection.tsx`;继续使用 `SettingsCategoryHeader`、`settings-section`、`field`,不套第二个 PageShell 或一级标题 |
|
| 设置中心 | `SettingsPanel.tsx` 整窗 Modal 内的 `DocumentParsingSettingsSection.tsx`;继续使用 `SettingsCategoryHeader`、`settings-section`、`field`,不套第二个 PageShell 或一级标题 |
|
||||||
| 聊天 | `App.tsx` 的 `PageShell variant="reading"`;附件卡位于原 Composer 内,待发送队列仍在 Composer 外,不在助手工作栏增加附件栏目 |
|
| 聊天 | `App.tsx` 的 `PageShell variant="reading"`;附件卡位于原 Composer 内,待发送队列仍在 Composer 外,不在助手工作栏增加附件栏目 |
|
||||||
| 知识库 | `App.tsx` 已提供 `PageShell variant="master-detail"`,`KnowledgeWorkspace.tsx` 内含库导航与 DocumentsView;需在文档区域新增解析详情及返回列表,不再嵌套页面壳层或新建知识库级页签 |
|
| 知识库 | `App.tsx` 提供 `PageShell variant="master-detail"`,`KnowledgeWorkspace.tsx` 保留库导航并挂载 `knowledge-workspace/DocumentsView.tsx`;文档区域内切换解析详情与列表,不嵌套页面壳层或新建知识库级页签 |
|
||||||
| 解析预览 | 新增共享内容视图,分别嵌入设置诊断 Modal、聊天附件 Modal 和知识库文档详情;共享数据和交互,外层容器负责标题、范围、关闭与滚动 |
|
| 解析预览 | `DocumentResultPreview.tsx` 分别嵌入设置诊断 Modal、聊天附件 Modal 和知识库文档详情;共享数据和交互,外层容器负责标题、范围、关闭与滚动 |
|
||||||
| 页签与选择 | `WorkspacePrimitives.tsx` 的 `PageTabs variant="segmented"` 承载正文、图片、详情;来源及过滤模式使用 `SegmentedControl`,不能用它替换页签语义 |
|
| 页签与选择 | `WorkspacePrimitives.tsx` 的 `PageTabs variant="segmented"` 承载正文、图片、详情;来源及过滤模式使用 `SegmentedControl`,不能用它替换页签语义 |
|
||||||
| 浮层 | Modal 沿用 body Portal、`activateModalFocus` 与 `trapTabFocus`;测试及附件菜单复用 `AnchoredMenu`,由共享 `FloatingPortal` 挂入所属 Modal 并在 top layer 绘制。通知遵循根 UI 规范 |
|
| 浮层 | Modal 沿用 body Portal、`activateModalFocus` 与 `trapTabFocus`;测试及附件菜单复用 `AnchoredMenu`,由共享 `FloatingPortal` 挂入所属 Modal 并在 top layer 绘制。通知遵循根 UI 规范 |
|
||||||
| 图文内容 | 复用 `MarkdownRenderer.tsx` 的排版、表格滚动和静态 HTML 处理边界;补齐受管图片与 OCR HTML 表格适配,现有 Markdown 渲染器并未直接支持这些资源 |
|
| 图文内容 | 复用 `MarkdownRenderer.tsx` 的排版、表格滚动和静态 HTML 处理边界;补齐受管图片与 OCR HTML 表格适配,现有 Markdown 渲染器并未直接支持这些资源 |
|
||||||
@@ -36,8 +36,8 @@
|
|||||||
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;全局模型下载源仍在“平台功能 / 通用设置”,与 OCR 来源选择分开 |
|
| 管理本地 OCR 文件 | 同一“文档解析”分类的本地模型区 | 下载、校验、ZIP、删除和目录操作;全局模型下载源仍在“平台功能 / 通用设置”,与 OCR 来源选择分开 |
|
||||||
| 查看或释放本地 OCR 内存 | “应用中心” → “本机推理监控”(`local-inference` Modal),或其常驻入口 | 只管理本机执行服务;应用启用及常驻控制入口,不控制解析功能;HTTP 服务不出现在本机服务列表,也不提供远程启停 |
|
| 查看或释放本地 OCR 内存 | “应用中心” → “本机推理监控”(`local-inference` Modal),或其常驻入口 | 只管理本机执行服务;应用启用及常驻控制入口,不控制解析功能;HTTP 服务不出现在本机服务列表,也不提供远程启停 |
|
||||||
| 添加文件或图片 | “当前对话” → “添加附件”,或输入框粘贴 | 先加入当前草稿,再按输入类型处理;上传按钮继续直接选文件,不改成先选 OCR 的菜单 |
|
| 添加文件或图片 | “当前对话” → “添加附件”,或输入框粘贴 | 先加入当前草稿,再按输入类型处理;上传按钮继续直接选文件,不改成先选 OCR 的菜单 |
|
||||||
| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 规划中的共享预览;来源消息与目标草稿分别显示,历史输入只读 |
|
| 查看聊天解析结果 | 草稿或历史消息的附件卡 | 打开共享预览;来源消息与目标草稿分别显示,历史输入只读 |
|
||||||
| 查看知识文档结果 | “知识库” → 选库 → “文档与来源”中的文档操作 | 规划新增“查看解析结果”和文档详情;返回恢复库、列表筛选和位置;不改变活动会话 |
|
| 查看知识文档结果 | “知识库” → 选库 → “文档与来源”中的文档操作 | “查看解析结果”进入文档详情;返回恢复库、列表筛选和位置;不改变活动会话 |
|
||||||
| 把文档插图用于聊天 | 聊天或知识文档预览 → “图片” → “添加所选图片” | 明确目标会话草稿后复制;设置测试预览无此操作;已有“用于当前对话”仍负责知识库检索范围,不代替选图 |
|
| 把文档插图用于聊天 | 聊天或知识文档预览 → “图片” → “添加所选图片” | 明确目标会话草稿后复制;设置测试预览无此操作;已有“用于当前对话”仍负责知识库检索范围,不代替选图 |
|
||||||
|
|
||||||
从解析失败或未配置提示提供“前往文档解析设置”时,须直达 `document-parsing` 分类并保留
|
从解析失败或未配置提示提供“前往文档解析设置”时,须直达 `document-parsing` 分类并保留
|
||||||
|
|||||||
@@ -37,6 +37,24 @@
|
|||||||
| `src/renderer/src/ExternalKnowledge.tsx` | 实例 Modal、绑定表单、Provider 配置、外部详情与检索结果 |
|
| `src/renderer/src/ExternalKnowledge.tsx` | 实例 Modal、绑定表单、Provider 配置、外部详情与检索结果 |
|
||||||
| `KnowledgeWorkspace.tsx`、`App.tsx`、`KnowledgeCitationDialog.tsx` | 统一列表、聊天选择、刷新及历史引用显示 |
|
| `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 脱离知识库祖先后
|
`ExternalKnowledge.tsx` 的字段改用全局 `.field`,修正 body Portal 脱离知识库祖先后
|
||||||
丢失 scoped 控件样式的问题。CSS 提供 560px 紧凑弹窗和粘附底部操作区;实例选择复用
|
丢失 scoped 控件样式的问题。CSS 提供 560px 紧凑弹窗和粘附底部操作区;实例选择复用
|
||||||
既有 picker 样式并实现菜单键盘行为。没有新增 Main 模块,具体控件规则由
|
既有 picker 样式并实现菜单键盘行为。没有新增 Main 模块,具体控件规则由
|
||||||
|
|||||||
+12
-410
@@ -26,7 +26,6 @@ import {
|
|||||||
} from "../../shared/application-settings-contracts";
|
} from "../../shared/application-settings-contracts";
|
||||||
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
import type { ImageOperation } from "../../shared/image-generation-contracts";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
|
||||||
Bot,
|
Bot,
|
||||||
ChartColumn,
|
ChartColumn,
|
||||||
Check,
|
Check,
|
||||||
@@ -168,13 +167,18 @@ import {
|
|||||||
normalizeInteractiveWorkMode,
|
normalizeInteractiveWorkMode,
|
||||||
projectChannelLabels,
|
projectChannelLabels,
|
||||||
} from "../../shared/assistant-contracts";
|
} from "../../shared/assistant-contracts";
|
||||||
import {
|
import type {
|
||||||
ChatTimeline,
|
ImageViewerItem,
|
||||||
type ImageViewerItem,
|
Message,
|
||||||
type Message,
|
SubagentActivity,
|
||||||
type SubagentActivity,
|
ToolActivity,
|
||||||
type ToolActivity,
|
|
||||||
} from "./ChatTimeline";
|
} from "./ChatTimeline";
|
||||||
|
import {
|
||||||
|
ChatHistoryPane,
|
||||||
|
messageRenderBatchSize,
|
||||||
|
type ChatScrollSnapshot,
|
||||||
|
} from "./ChatHistoryPane";
|
||||||
|
import { isUnusedConversation, type Conversation } from "./chat-conversation";
|
||||||
import {
|
import {
|
||||||
clearLegacyActivityHistory,
|
clearLegacyActivityHistory,
|
||||||
loadLegacyActivityHistory,
|
loadLegacyActivityHistory,
|
||||||
@@ -246,10 +250,7 @@ import type { RemoteProjectRecoveryState } from "../../shared/remote-project-rec
|
|||||||
import { ReleaseNotesDialog } from "./ReleaseNotesDialog";
|
import { ReleaseNotesDialog } from "./ReleaseNotesDialog";
|
||||||
import { scheduleIdleRoutePreload } from "./idle-route-preload";
|
import { scheduleIdleRoutePreload } from "./idle-route-preload";
|
||||||
import { createPreloadableComponent } from "./preloadable-component";
|
import { createPreloadableComponent } from "./preloadable-component";
|
||||||
import {
|
import { formatConversationListTime } from "./time-format";
|
||||||
formatConversationListTime,
|
|
||||||
type TimeFormatLocale,
|
|
||||||
} from "./time-format";
|
|
||||||
import { formatMediumDateTime } from "./locale-formatters";
|
import { formatMediumDateTime } from "./locale-formatters";
|
||||||
import { formatCompactTokens } from "./token-format";
|
import { formatCompactTokens } from "./token-format";
|
||||||
import {
|
import {
|
||||||
@@ -294,7 +295,6 @@ const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component;
|
|||||||
const SettingsPanel = settingsPanelRoute.Component;
|
const SettingsPanel = settingsPanelRoute.Component;
|
||||||
const ActivityPanel = activityPanelRoute.Component;
|
const ActivityPanel = activityPanelRoute.Component;
|
||||||
|
|
||||||
const messageRenderBatchSize = 80;
|
|
||||||
const conversationPersistenceIntervalMs = 500;
|
const conversationPersistenceIntervalMs = 500;
|
||||||
const conversationSearchSnapshotDelayMs = 250;
|
const conversationSearchSnapshotDelayMs = 250;
|
||||||
const keepAliveExpirationMs = 60 * 60 * 1_000;
|
const keepAliveExpirationMs = 60 * 60 * 1_000;
|
||||||
@@ -527,10 +527,6 @@ function supportsSubagentSmartRouting(workMode: string): boolean {
|
|||||||
return workMode === "ask";
|
return workMode === "ask";
|
||||||
}
|
}
|
||||||
|
|
||||||
type Conversation = Omit<ConversationListSnapshot, "messages"> & {
|
|
||||||
messages: Message[];
|
|
||||||
};
|
|
||||||
|
|
||||||
type ActiveRun = {
|
type ActiveRun = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
@@ -757,8 +753,6 @@ function isErrorRepresentedByFailedTool(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatBottomProximity = 96;
|
|
||||||
|
|
||||||
function createConversation(
|
function createConversation(
|
||||||
projectId?: string,
|
projectId?: string,
|
||||||
runtimeSelection?: AgentRuntimeSelection,
|
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(
|
function getConversationDisplayTitle(
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
defaultTitle: string,
|
defaultTitle: string,
|
||||||
@@ -831,17 +817,6 @@ function getConversationDisplayTitle(
|
|||||||
return isUnusedConversation(conversation) ? defaultTitle : conversation.title;
|
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 }: {
|
function ConversationHistoryLoader({ conversationId, active, load }: {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -866,379 +841,6 @@ function ConversationHistoryLoader({ conversationId, active, load }: {
|
|||||||
</section>;
|
</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(
|
function isConversationAttachment(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is ConversationAttachment {
|
): 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