Compare commits
6
Commits
9c0a7a2864
...
a6ea97142a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6ea97142a | ||
|
|
4374c89a63 | ||
|
|
224d53a04d | ||
|
|
7d2ae87e39 | ||
|
|
1e872ca873 | ||
|
|
e2a6bb0e7d |
+3
-2
@@ -26,8 +26,7 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env.local
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|
||||||
@@ -35,3 +34,5 @@ yarn-error.log*
|
|||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
memery.md
|
memery.md
|
||||||
|
|
||||||
|
docs/
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Chat 流式生成动画改造经验
|
||||||
|
|
||||||
|
本文记录 `src/components/chat` 里本次文字生成、图表生成、滚动稳定性的改造经验。重点不是复盘代码行数,而是总结后续继续调整时应遵守的工程边界和交互原则。
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
- 文本生成要有连续感,避免 token 直接到达导致忽快忽慢。
|
||||||
|
- 已生成内容必须稳定,不能反复淡入、重排或闪烁。
|
||||||
|
- 图表和工具调用插入时不能让“分析结果”边框剧烈抖动。
|
||||||
|
- 底部自动滚动要跟随,但不能每个 token 都强制贴底。
|
||||||
|
- 动画应辅助理解,不能比内容本身更抢眼。
|
||||||
|
|
||||||
|
## 文本流式生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
不要把后端 token 到达节奏直接暴露给 UI。后端 token 通常不均匀,前端如果每个 token 都立即渲染,会出现文字跳动、滚动频繁、动画看不出来等问题。
|
||||||
|
|
||||||
|
更稳的做法是类似 Vercel AI SDK `smoothStream` 的思路:
|
||||||
|
|
||||||
|
- token 先进入缓冲区。
|
||||||
|
- 前端按固定节奏释放 chunk。
|
||||||
|
- chunk 尽量按词、短语、标点边界切分。
|
||||||
|
- 当缓冲积压较大时,自适应加快 drain,避免显示落后真实输出太多。
|
||||||
|
|
||||||
|
当前实现采用:
|
||||||
|
|
||||||
|
- `TOKEN_PLAYBACK_INTERVAL_MS = 16`
|
||||||
|
- 小缓冲按较短 chunk 输出。
|
||||||
|
- 大缓冲最多每帧释放 `160` 字符。
|
||||||
|
- 中文优先使用 `Intl.Segmenter("zh", { granularity: "word" })`。
|
||||||
|
- 非 token 事件前强制 flush,保证工具调用、done、error 的顺序正确。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只做 `setTimeout(120ms)` 批量 flush 不够。它只是减少更新次数,并不能形成稳定播放节奏。
|
||||||
|
- interval 太小,例如 `8ms`,浏览器调度不一定更稳定,反而可能增加 React 更新压力。
|
||||||
|
- 中文按 `Intl.Segmenter` 的单个词输出会显得慢,必须结合缓冲长度动态放大 chunk。
|
||||||
|
- `done`、`error`、`tool_call` 前如果不 flush,会造成文本和结构事件顺序错乱。
|
||||||
|
|
||||||
|
## Markdown 动画
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
Markdown 是流式文本动画里最容易出问题的部分。原因是 `ReactMarkdown` 每次都会重新解析完整内容,原始 Markdown 字符索引和最终 DOM 文本节点索引不一致。
|
||||||
|
|
||||||
|
典型例子:
|
||||||
|
|
||||||
|
- `**加粗**` 的原始长度包含 `**`,但可见文本不包含。
|
||||||
|
- 列表符号、链接语法、代码块围栏都可能影响原始索引。
|
||||||
|
- 新增文本可能落在 `p`、`li`、`strong`、`code` 等不同节点里。
|
||||||
|
|
||||||
|
因此不要简单用原始 `text.length` 或 `fadeFrom` 去对应 Markdown 渲染后的 DOM 文本。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- Markdown 仍完整解析,保证格式正确。
|
||||||
|
- 在 rehype 阶段处理 AST。
|
||||||
|
- 从 AST 尾部反向找最后的可见 text node。
|
||||||
|
- 只给最后一段尾部文本加动画。
|
||||||
|
- 每次最多动画最后 `48` 个字符,避免大 chunk 整段闪烁。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 用 `text.length` 作为 React key 会导致整段 Markdown remount,所有文本都会重新淡入。
|
||||||
|
- CSS animation 和 Web Animations 同时作用在同一个 span 上,会出现闪烁或动画重启。
|
||||||
|
- 在 render 阶段读写 ref 会触发 React hooks lint 规则,也容易产生不可控渲染。
|
||||||
|
- 反向遍历 AST 时拆分 text node 要注意顺序。使用 `unshift` 时应先插入动画尾巴,再插入稳定文本,最终 DOM 才是“稳定文本在前,动画尾巴在后”。
|
||||||
|
|
||||||
|
## 当前文字动画建议
|
||||||
|
|
||||||
|
推荐保留轻量动画:
|
||||||
|
|
||||||
|
- 使用 Web Animations,在 `useLayoutEffect` 中启动,避免先完整显示一帧再裁切。
|
||||||
|
- 使用 `clip-path` 做左到右 reveal。
|
||||||
|
- 叠加轻微 opacity:当前约 `0.46 -> 1`。
|
||||||
|
- 时长控制在 `120ms - 260ms`。
|
||||||
|
|
||||||
|
不要做:
|
||||||
|
|
||||||
|
- 外层整段 `motion.div` 淡入。
|
||||||
|
- 每次流式更新都改变 key。
|
||||||
|
- 对整个 Markdown AST 的新增范围大面积包 span。
|
||||||
|
- 在生成中对已有文本重复动画。
|
||||||
|
|
||||||
|
## 滚动和边框稳定
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
滚动条在最底部时,内容增长会不断改变 `scrollTop`。如果每个 token 都执行 `scrollTop = scrollHeight` 或 `scrollIntoView`,最后一个 assistant turn 的边框会产生明显抖动。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 生成中不再每个 token 精确贴底。
|
||||||
|
- 底部保留生成缓冲区,当前约 `180px`。
|
||||||
|
- 只有缓冲被消耗到阈值后才恢复滚动。
|
||||||
|
- 用户离开底部附近后,不再强制自动跟随。
|
||||||
|
- 使用 `scrollbar-gutter: stable` 减少滚动条出现/消失造成的宽度变化。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- “锁最大高度”不是正确方向。问题不是高度无限增长,而是底部锚定过于频繁。
|
||||||
|
- 每 token 自动滚动会把视口不断向下推,视觉上就是边框抖动。
|
||||||
|
- 滚动判断阈值要和底部缓冲一致,否则缓冲刚出现就被判断为“离开底部”。
|
||||||
|
|
||||||
|
## 图表生成
|
||||||
|
|
||||||
|
### 经验结论
|
||||||
|
|
||||||
|
图表不能等数据到达后突然插入。图表生成应先占位,再 crossfade,再让图表内部动画接管。
|
||||||
|
|
||||||
|
当前策略:
|
||||||
|
|
||||||
|
- 工具调用 pending 时使用固定尺寸 `ChartGenerationSkeleton`。
|
||||||
|
- 图表真实数据到达后,继续短暂保留 skeleton overlay。
|
||||||
|
- ECharts 在 skeleton 下方淡入。
|
||||||
|
- 容器尺寸保持一致,避免边框高度突变。
|
||||||
|
- ECharts 内部使用 enter/update 动画,而不是外层布局动画。
|
||||||
|
|
||||||
|
图表类型动画建议:
|
||||||
|
|
||||||
|
- 折线图:平滑 enter,面积渐显。
|
||||||
|
- 柱状图:柱子从基线增长,并对数据点轻微 stagger。
|
||||||
|
- 饼图:使用 expansion/sweep 类进入动画。
|
||||||
|
- update 动画要短于 enter 动画。
|
||||||
|
|
||||||
|
### 踩坑
|
||||||
|
|
||||||
|
- 只给外层图表卡片 fade in 不够,插入瞬间仍可能造成内容跳变。
|
||||||
|
- skeleton 和最终图表尺寸不一致,会导致边框先长再缩。
|
||||||
|
- 图表更新时不要重建组件,尽量让 ECharts diff 数据并执行内部 transition。
|
||||||
|
|
||||||
|
## 状态提示
|
||||||
|
|
||||||
|
“正在生成”状态是有价值的,应该保留。它承担了部分动感和系统状态反馈,不需要让文本动画本身过于夸张。
|
||||||
|
|
||||||
|
推荐:
|
||||||
|
|
||||||
|
- 状态放在“分析结果”标题行右侧。
|
||||||
|
- 使用小尺寸、低干扰的 pulsing dots。
|
||||||
|
- 不使用末尾光标,避免和业务文本混在一起。
|
||||||
|
|
||||||
|
## 验证建议
|
||||||
|
|
||||||
|
每次调整流式动画后至少跑:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx eslint src/components/chat/AgentMarkdownBlock.tsx src/components/chat/AgentTurn.tsx src/components/chat/ChatInlineChart.tsx src/components/chat/AgentWorkspace.tsx src/components/chat/GlobalChatbox.tsx src/components/chat/hooks/useAgentChatSession.ts
|
||||||
|
npx tsc --noEmit
|
||||||
|
npm test -- src/components/chat/hooks/useAgentChatSession.lifecycle.test.tsx src/components/chat/hooks/useAgentChatSession.actions.test.tsx src/components/chat/AgentWorkspace.test.tsx src/components/chat/ChatInlineChart.test.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
人工验证重点:
|
||||||
|
|
||||||
|
- 长中文回答是否明显落后后端真实速度。
|
||||||
|
- Markdown 加粗、列表、代码块是否乱序。
|
||||||
|
- 底部自动滚动时“分析结果”边框是否抖动。
|
||||||
|
- 工具调用 pending 到图表出现时是否有高度跳变。
|
||||||
|
- 用户手动上滚后是否停止强制跟随。
|
||||||
|
|
||||||
|
## 后续调整原则
|
||||||
|
|
||||||
|
1. 先调节 token playback,再调动画。
|
||||||
|
2. 动画只作用于新增内容,已有内容不能重播。
|
||||||
|
3. Markdown 动画优先保守,宁可弱一点,也不能破坏文本顺序。
|
||||||
|
4. 图表和工具调用先稳定布局,再考虑视觉效果。
|
||||||
|
5. 滚动跟随要有缓冲,不能逐 token 贴底。
|
||||||
@@ -28,6 +28,7 @@ import BoltRounded from "@mui/icons-material/BoltRounded";
|
|||||||
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
import AutoAwesomeRounded from "@mui/icons-material/AutoAwesomeRounded";
|
||||||
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
import VerifiedUserRounded from "@mui/icons-material/VerifiedUserRounded";
|
||||||
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
import AdminPanelSettingsRounded from "@mui/icons-material/AdminPanelSettingsRounded";
|
||||||
|
import type { AgentModelOption } from "@/lib/chatModels";
|
||||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
|
|
||||||
export type AgentComposerHandle = {
|
export type AgentComposerHandle = {
|
||||||
@@ -48,12 +49,23 @@ type AgentComposerProps = {
|
|||||||
onAbort: () => void;
|
onAbort: () => void;
|
||||||
onStartListening: () => void;
|
onStartListening: () => void;
|
||||||
onStopListening: () => void;
|
onStopListening: () => void;
|
||||||
selectedModel: AgentModel;
|
modelOptions: AgentModelOption[];
|
||||||
|
selectedModel?: AgentModel;
|
||||||
onModelChange: (model: AgentModel) => void;
|
onModelChange: (model: AgentModel) => void;
|
||||||
approvalMode: AgentApprovalMode;
|
approvalMode: AgentApprovalMode;
|
||||||
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
onApprovalModeChange: (mode: AgentApprovalMode) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderModelIcon = (
|
||||||
|
icon: AgentModelOption["icon"] | undefined,
|
||||||
|
props?: React.ComponentProps<typeof BoltRounded>,
|
||||||
|
) =>
|
||||||
|
icon === "bolt" ? (
|
||||||
|
<BoltRounded {...props} />
|
||||||
|
) : (
|
||||||
|
<AutoAwesomeRounded {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposerProps>(function AgentComposer({
|
||||||
isHydrating = false,
|
isHydrating = false,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@@ -64,6 +76,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
onAbort,
|
onAbort,
|
||||||
onStartListening,
|
onStartListening,
|
||||||
onStopListening,
|
onStopListening,
|
||||||
|
modelOptions,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
approvalMode,
|
approvalMode,
|
||||||
@@ -74,6 +87,7 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
const [input, setInput] = React.useState("");
|
const [input, setInput] = React.useState("");
|
||||||
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
const [isPresetOpen, setIsPresetOpen] = React.useState(false);
|
||||||
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
const canSend = input.trim().length > 0 && !isStreaming && !isHydrating;
|
||||||
|
const selectedModelOption = modelOptions.find((model) => model.id === selectedModel);
|
||||||
|
|
||||||
React.useImperativeHandle(
|
React.useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -347,19 +361,21 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
<Stack direction="row" spacing={1} alignItems="center">
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
<FormControl size="small" sx={{ minWidth: 80 }}>
|
<FormControl size="small" sx={{ minWidth: 80 }}>
|
||||||
<Select
|
<Select
|
||||||
value={selectedModel}
|
value={selectedModel ?? ""}
|
||||||
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
onChange={(event) => onModelChange(event.target.value as AgentModel)}
|
||||||
disabled={isHydrating || isStreaming}
|
disabled={isHydrating || isStreaming || modelOptions.length === 0}
|
||||||
aria-label="模型选择"
|
aria-label="模型选择"
|
||||||
renderValue={(val) => (
|
renderValue={() => (
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
|
||||||
{val === "deepseek/deepseek-v4-flash" ? (
|
{renderModelIcon(selectedModelOption?.icon, {
|
||||||
<BoltRounded sx={{ fontSize: 18, color: "inherit", transition: "color 0.2s" }} />
|
sx: {
|
||||||
) : (
|
fontSize: selectedModelOption?.icon === "bolt" ? 18 : 16,
|
||||||
<AutoAwesomeRounded sx={{ fontSize: 16, color: "inherit", transition: "color 0.2s" }} />
|
color: "inherit",
|
||||||
)}
|
transition: "color 0.2s",
|
||||||
|
},
|
||||||
|
})}
|
||||||
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
<Typography sx={{ fontSize: "0.8rem", fontWeight: 600, color: "inherit", transition: "color 0.2s" }}>
|
||||||
{val === "deepseek/deepseek-v4-flash" ? "快速" : "专家"}
|
{selectedModelOption?.label ?? "模型"}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -433,30 +449,25 @@ export const AgentComposer = React.forwardRef<AgentComposerHandle, AgentComposer
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
<Box sx={{ px: 2, py: 1.5, pb: 1, display: "flex", alignItems: "center", gap: 1, pointerEvents: "none" }}>
|
||||||
<Box
|
<AutoAwesomeRounded sx={{ width: 16, height: 16, color: "text.secondary", flexShrink: 0 }} />
|
||||||
component="img"
|
|
||||||
src="/deepseek-logo.svg"
|
|
||||||
alt="DeepSeek"
|
|
||||||
sx={{ width: 16, height: 16, display: "block", flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
<Typography sx={{ fontSize: "0.75rem", fontWeight: 700, color: "text.secondary", letterSpacing: 0.5 }}>
|
||||||
DEEPSEEK V4
|
模型选择
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<MenuItem value="deepseek/deepseek-v4-flash">
|
{modelOptions.map((model) => (
|
||||||
<BoltRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 20, color: "text.secondary", transition: "color 0.2s" }} />
|
<MenuItem key={model.id} value={model.id}>
|
||||||
|
{renderModelIcon(model.icon, {
|
||||||
|
className: "icon",
|
||||||
|
sx: { mr: 1.5, mt: 0.2, fontSize: model.icon === "bolt" ? 20 : 18, color: "text.secondary", transition: "color 0.2s" },
|
||||||
|
})}
|
||||||
<Box>
|
<Box>
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>快速</Typography>
|
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>{model.label}</Typography>
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>快速回答和任务执行</Typography>
|
{model.description ? (
|
||||||
</Box>
|
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>{model.description}</Typography>
|
||||||
</MenuItem>
|
) : null}
|
||||||
<MenuItem value="deepseek/deepseek-v4-pro">
|
|
||||||
<AutoAwesomeRounded className="icon" sx={{ mr: 1.5, mt: 0.2, fontSize: 18, color: "text.secondary", transition: "color 0.2s" }} />
|
|
||||||
<Box>
|
|
||||||
<Typography className="title" sx={{ fontSize: "0.85rem", fontWeight: 700, color: "text.primary", mb: 0.2, transition: "color 0.2s" }}>专家</Typography>
|
|
||||||
<Typography sx={{ fontSize: "0.7rem", fontWeight: 500, color: "text.secondary", lineHeight: 1.3 }}>探索、解决复杂任务</Typography>
|
|
||||||
</Box>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,141 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import type { Element, Root, RootContent, Text } from "hast";
|
||||||
|
import ReactMarkdown, { type Components } from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
|
|
||||||
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
import markdownStyles from "./GlobalChatboxMarkdown.module.css";
|
||||||
|
|
||||||
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
export const normalizeClipboardText = (value: string) => value.replace(/\s+$/u, "");
|
||||||
|
|
||||||
export const MarkdownBlock = ({ children }: { children: string }) => {
|
const isTextNode = (node: RootContent): node is Text => node.type === "text";
|
||||||
|
|
||||||
|
const isElementNode = (node: RootContent): node is Element => node.type === "element";
|
||||||
|
|
||||||
|
const createFadeSpan = (value: string, fadeKey: string): Element => ({
|
||||||
|
type: "element",
|
||||||
|
tagName: "span",
|
||||||
|
properties: {
|
||||||
|
className: [markdownStyles.streamFade],
|
||||||
|
dataStreamFadeKey: fadeKey,
|
||||||
|
dataStreamRevealLength: value.length,
|
||||||
|
},
|
||||||
|
children: [{ type: "text", value }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const splitTextTail = (value: string, tailLength: number) => {
|
||||||
|
const codePoints = Array.from(value);
|
||||||
|
const stableText = codePoints.slice(0, -tailLength).join("");
|
||||||
|
const animatedText = codePoints.slice(-tailLength).join("");
|
||||||
|
return { stableText, animatedText };
|
||||||
|
};
|
||||||
|
|
||||||
|
const createStreamFadePlugin = (fadeLength: number, fadeKey: string) => {
|
||||||
|
return () => (tree: Root) => {
|
||||||
|
let remainingFadeLength = fadeLength;
|
||||||
|
|
||||||
|
const visitChildren = (parent: Element | Root) => {
|
||||||
|
const nextChildren: RootContent[] = [];
|
||||||
|
|
||||||
|
for (let index = parent.children.length - 1; index >= 0; index -= 1) {
|
||||||
|
const child = (parent.children as RootContent[])[index];
|
||||||
|
if (isTextNode(child)) {
|
||||||
|
if (!child.value.trim()) {
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingFadeLength <= 0) {
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textLength = Array.from(child.value).length;
|
||||||
|
const tailLength = Math.min(textLength, remainingFadeLength);
|
||||||
|
const { stableText, animatedText } = splitTextTail(child.value, tailLength);
|
||||||
|
remainingFadeLength -= tailLength;
|
||||||
|
|
||||||
|
if (animatedText) {
|
||||||
|
nextChildren.unshift(createFadeSpan(animatedText, fadeKey));
|
||||||
|
}
|
||||||
|
if (stableText) {
|
||||||
|
nextChildren.unshift({ ...child, value: stableText });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isElementNode(child)) {
|
||||||
|
visitChildren(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextChildren.unshift(child);
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.children = nextChildren as typeof parent.children;
|
||||||
|
};
|
||||||
|
|
||||||
|
visitChildren(tree);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamFadeSpan: Components["span"] = ({ node, children, ...props }) => {
|
||||||
|
const ref = React.useRef<HTMLSpanElement>(null);
|
||||||
|
const fadeKeyValue = node?.properties?.dataStreamFadeKey;
|
||||||
|
const fadeKey = typeof fadeKeyValue === "string" ? fadeKeyValue : undefined;
|
||||||
|
const revealLengthValue = node?.properties?.dataStreamRevealLength;
|
||||||
|
const revealLength =
|
||||||
|
typeof revealLengthValue === "number"
|
||||||
|
? revealLengthValue
|
||||||
|
: typeof revealLengthValue === "string"
|
||||||
|
? Number.parseInt(revealLengthValue, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!fadeKey) return;
|
||||||
|
|
||||||
|
const element = ref.current;
|
||||||
|
if (!element) return;
|
||||||
|
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
|
||||||
|
|
||||||
|
const duration = Math.min(260, Math.max(120, revealLength * 14));
|
||||||
|
const animation = element.animate(
|
||||||
|
[
|
||||||
|
{ clipPath: "inset(0 100% 0 0)", opacity: 0.46 },
|
||||||
|
{ clipPath: "inset(0 0% 0 0)", opacity: 1 },
|
||||||
|
],
|
||||||
|
{
|
||||||
|
duration,
|
||||||
|
easing: "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
fill: "both",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
animation.cancel();
|
||||||
|
};
|
||||||
|
}, [fadeKey, revealLength]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span {...props} ref={ref}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markdownComponents: Components = {
|
||||||
|
span: StreamFadeSpan,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MarkdownBlock = ({
|
||||||
|
children,
|
||||||
|
streamFadeKey,
|
||||||
|
streamFadeLength,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
streamFadeKey?: string;
|
||||||
|
streamFadeLength?: number | null;
|
||||||
|
}) => {
|
||||||
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
const handleCopy = React.useCallback((event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||||
const selectedText = window.getSelection()?.toString();
|
const selectedText = window.getSelection()?.toString();
|
||||||
if (!selectedText) return;
|
if (!selectedText) return;
|
||||||
@@ -16,12 +143,26 @@ export const MarkdownBlock = ({ children }: { children: string }) => {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
event.clipboardData.setData("text/plain", normalizeClipboardText(selectedText));
|
||||||
}, []);
|
}, []);
|
||||||
|
const rehypePlugins = React.useMemo(
|
||||||
|
() =>
|
||||||
|
typeof streamFadeLength === "number" && streamFadeLength > 0
|
||||||
|
? [createStreamFadePlugin(
|
||||||
|
streamFadeLength,
|
||||||
|
streamFadeKey ?? `stream-tail-${children.length}`,
|
||||||
|
)]
|
||||||
|
: [],
|
||||||
|
[children.length, streamFadeKey, streamFadeLength],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
<div className={markdownStyles.markdown} onCopy={handleCopy}>
|
||||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{children}</ReactMarkdown>
|
<ReactMarkdown
|
||||||
|
components={markdownComponents}
|
||||||
|
remarkPlugins={[remarkGfm]}
|
||||||
|
rehypePlugins={rehypePlugins}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ReactMarkdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
import type { Message, SpeechState } from "./GlobalChatbox.types";
|
||||||
import { stripMarkdown } from "./GlobalChatbox.utils";
|
import { stripMarkdown } from "./GlobalChatbox.utils";
|
||||||
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
import { AgentProgressTimeline } from "./AgentProgressTimeline";
|
||||||
import { ChatInlineChart } from "./ChatInlineChart";
|
import { ChartGenerationSkeleton, ChatInlineChart } from "./ChatInlineChart";
|
||||||
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
import { ChatToolCallBlock } from "./ChatToolCallBlock";
|
||||||
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
import { MarkdownBlock, normalizeClipboardText } from "./AgentMarkdownBlock";
|
||||||
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
import { PermissionRequestGroup } from "./AgentPermissionRequests";
|
||||||
@@ -51,6 +51,105 @@ type AgentTurnProps = {
|
|||||||
onRejectQuestion: (requestId: string) => void;
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const StreamingStatus = () => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction="row"
|
||||||
|
spacing={0.75}
|
||||||
|
alignItems="center"
|
||||||
|
sx={{
|
||||||
|
px: 1,
|
||||||
|
py: 0.35,
|
||||||
|
borderRadius: 999,
|
||||||
|
bgcolor: alpha(theme.palette.primary.main, 0.07),
|
||||||
|
color: "text.secondary",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Stack direction="row" spacing={0.35} alignItems="center">
|
||||||
|
{[0, 1, 2].map((index) => (
|
||||||
|
<motion.span
|
||||||
|
key={index}
|
||||||
|
animate={{ opacity: [0.28, 0.86, 0.28] }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.95,
|
||||||
|
repeat: Infinity,
|
||||||
|
delay: index * 0.14,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
width: 4,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: theme.palette.primary.main,
|
||||||
|
display: "block",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
<Typography variant="caption" color="text.secondary" fontWeight={700}>
|
||||||
|
正在生成
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const StreamingMarkdownBlock = ({
|
||||||
|
text,
|
||||||
|
isStreaming,
|
||||||
|
segmentKey,
|
||||||
|
}: {
|
||||||
|
text: string;
|
||||||
|
isStreaming: boolean;
|
||||||
|
segmentKey: string;
|
||||||
|
}) => {
|
||||||
|
const [streamTextState, setStreamTextState] = React.useState<{
|
||||||
|
displayText: string;
|
||||||
|
animatedTailLength: number;
|
||||||
|
}>({
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
setStreamTextState((current) => {
|
||||||
|
if (current.displayText === text) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStreaming) {
|
||||||
|
return {
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.displayText === text) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayText: text,
|
||||||
|
animatedTailLength:
|
||||||
|
text.length > current.displayText.length &&
|
||||||
|
text.startsWith(current.displayText)
|
||||||
|
? Math.min(48, text.length - current.displayText.length)
|
||||||
|
: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [isStreaming, text]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MarkdownBlock
|
||||||
|
streamFadeKey={`${segmentKey}-${streamTextState.displayText.length}`}
|
||||||
|
streamFadeLength={streamTextState.animatedTailLength}
|
||||||
|
>
|
||||||
|
{streamTextState.displayText}
|
||||||
|
</MarkdownBlock>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const AgentTurn = React.memo(
|
export const AgentTurn = React.memo(
|
||||||
({
|
({
|
||||||
message,
|
message,
|
||||||
@@ -69,6 +168,7 @@ export const AgentTurn = React.memo(
|
|||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isUser = message.role === "user";
|
const isUser = message.role === "user";
|
||||||
const isErrorMessage = Boolean(message.isError);
|
const isErrorMessage = Boolean(message.isError);
|
||||||
|
const isStreamingAssistant = !isUser && !isErrorMessage && isStreaming;
|
||||||
const [isHovered, setIsHovered] = React.useState(false);
|
const [isHovered, setIsHovered] = React.useState(false);
|
||||||
const isProgressComplete = message.progress?.some(
|
const isProgressComplete = message.progress?.some(
|
||||||
(item) => item.phase === "complete" && item.status === "completed",
|
(item) => item.phase === "complete" && item.status === "completed",
|
||||||
@@ -238,17 +338,28 @@ export const AgentTurn = React.memo(
|
|||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
bgcolor: alpha("#fff", 0.4),
|
bgcolor: alpha("#fff", 0.4),
|
||||||
border: `1px solid ${alpha("#fff", 0.6)}`,
|
border: `1px solid ${alpha("#fff", 0.6)}`,
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack spacing={1.2}>
|
<Stack spacing={1.2}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
||||||
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
<Typography variant="caption" color="text.secondary" fontWeight={800} sx={{ letterSpacing: 0.5 }}>
|
||||||
分析结果
|
分析结果
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{isStreamingAssistant ? <StreamingStatus /> : null}
|
||||||
|
</Stack>
|
||||||
{contentSegments.map((segment, segIdx) => {
|
{contentSegments.map((segment, segIdx) => {
|
||||||
if (segment.type === "text") {
|
if (segment.type === "text") {
|
||||||
const text = segment.content.trim();
|
const text = segment.content.trim();
|
||||||
if (!text && contentSegments.length > 1) return null;
|
if (!text && contentSegments.length > 1) return null;
|
||||||
return <MarkdownBlock key={segIdx}>{text || "..."}</MarkdownBlock>;
|
return (
|
||||||
|
<StreamingMarkdownBlock
|
||||||
|
key={segIdx}
|
||||||
|
text={text || "..."}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
|
segmentKey={`${message.id}-${segIdx}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (segment.type === "tool_call") {
|
if (segment.type === "tool_call") {
|
||||||
if (
|
if (
|
||||||
@@ -267,6 +378,7 @@ export const AgentTurn = React.memo(
|
|||||||
series={p.series}
|
series={p.series}
|
||||||
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
x_axis_name={(p.x_axis_name as string) ?? undefined}
|
||||||
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
y_axis_name={(p.y_axis_name as string) ?? undefined}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -279,9 +391,10 @@ export const AgentTurn = React.memo(
|
|||||||
}
|
}
|
||||||
if (segment.type === "tool_call_pending") {
|
if (segment.type === "tool_call_pending") {
|
||||||
return (
|
return (
|
||||||
<Typography key="tool-pending" variant="caption" color="text.secondary">
|
<ChartGenerationSkeleton
|
||||||
正在准备工具调用...
|
key="tool-pending"
|
||||||
</Typography>
|
status={<StreamingStatus />}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -306,6 +419,7 @@ export const AgentTurn = React.memo(
|
|||||||
series={artifact.params.series}
|
series={artifact.params.series}
|
||||||
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
x_axis_name={(artifact.params.x_axis_name as string) ?? undefined}
|
||||||
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
y_axis_name={(artifact.params.y_axis_name as string) ?? undefined}
|
||||||
|
isStreaming={isStreamingAssistant}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { AgentWorkspace } from "./AgentWorkspace";
|
|||||||
import type { Message } from "./GlobalChatbox.types";
|
import type { Message } from "./GlobalChatbox.types";
|
||||||
|
|
||||||
const renderCounts = new Map<string, number>();
|
const renderCounts = new Map<string, number>();
|
||||||
|
const mountCounts = new Map<string, number>();
|
||||||
|
const unmountCounts = new Map<string, number>();
|
||||||
|
const streamingFlags = new Map<string, boolean>();
|
||||||
|
|
||||||
jest.mock("next/image", () => ({
|
jest.mock("next/image", () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
@@ -16,7 +19,18 @@ jest.mock("next/image", () => ({
|
|||||||
jest.mock("framer-motion", () => ({
|
jest.mock("framer-motion", () => ({
|
||||||
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
AnimatePresence: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
motion: {
|
motion: {
|
||||||
div: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => <div {...props}>{children}</div>,
|
div: ({
|
||||||
|
children,
|
||||||
|
animate: _animate,
|
||||||
|
exit: _exit,
|
||||||
|
initial: _initial,
|
||||||
|
layout: _layout,
|
||||||
|
transition: _transition,
|
||||||
|
whileHover: _whileHover,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & Record<string, unknown>) => (
|
||||||
|
<div {...props}>{children}</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -25,8 +39,15 @@ jest.mock("./GlobalChatbox.parts", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock("./AgentTurn", () => ({
|
jest.mock("./AgentTurn", () => ({
|
||||||
AgentTurn: ({ message }: { message: Message }) => {
|
AgentTurn: ({ message, isStreaming }: { message: Message; isStreaming: boolean }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
mountCounts.set(message.id, (mountCounts.get(message.id) ?? 0) + 1);
|
||||||
|
return () => {
|
||||||
|
unmountCounts.set(message.id, (unmountCounts.get(message.id) ?? 0) + 1);
|
||||||
|
};
|
||||||
|
}, [message.id]);
|
||||||
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
renderCounts.set(message.id, (renderCounts.get(message.id) ?? 0) + 1);
|
||||||
|
streamingFlags.set(message.id, isStreaming);
|
||||||
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
return <div data-testid={`turn-${message.id}`}>{message.content}</div>;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -49,6 +70,9 @@ describe("AgentWorkspace", () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
renderCounts.clear();
|
renderCounts.clear();
|
||||||
|
mountCounts.clear();
|
||||||
|
unmountCounts.clear();
|
||||||
|
streamingFlags.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
|
it("shows a loading skeleton instead of the empty state while switching history sessions", () => {
|
||||||
@@ -106,5 +130,42 @@ describe("AgentWorkspace", () => {
|
|||||||
expect(renderCounts.get("user-1")).toBe(1);
|
expect(renderCounts.get("user-1")).toBe(1);
|
||||||
expect(renderCounts.get("assistant-1")).toBe(1);
|
expect(renderCounts.get("assistant-1")).toBe(1);
|
||||||
expect(renderCounts.get("assistant-2")).toBe(2);
|
expect(renderCounts.get("assistant-2")).toBe(2);
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
|
expect(streamingFlags.get("assistant-2")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not remount the streaming assistant turn when streaming finishes", () => {
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: "user-1",
|
||||||
|
role: "user",
|
||||||
|
content: "question",
|
||||||
|
};
|
||||||
|
const assistantMessage: Message = {
|
||||||
|
id: "assistant-1",
|
||||||
|
role: "assistant",
|
||||||
|
content: "final answer",
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming
|
||||||
|
messages={[userMessage, assistantMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(true);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<AgentWorkspace
|
||||||
|
{...defaultProps}
|
||||||
|
isStreaming={false}
|
||||||
|
messages={[userMessage, assistantMessage]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mountCounts.get("assistant-1")).toBe(1);
|
||||||
|
expect(unmountCounts.get("assistant-1") ?? 0).toBe(0);
|
||||||
|
expect(streamingFlags.get("assistant-1")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ type AgentWorkspaceProps = {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
isLoadingSession?: boolean;
|
isLoadingSession?: boolean;
|
||||||
|
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
|
||||||
bottomRef: React.RefObject<HTMLDivElement | null>;
|
bottomRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
onScrollStateChange?: (isNearBottom: boolean) => void;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
@@ -38,6 +40,7 @@ type AgentWorkspaceProps = {
|
|||||||
type TurnListProps = {
|
type TurnListProps = {
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
|
streamingMessageId: string | null;
|
||||||
speakingMessageId: string | null;
|
speakingMessageId: string | null;
|
||||||
speechState: SpeechState;
|
speechState: SpeechState;
|
||||||
onSpeak: (messageId: string, text: string) => void;
|
onSpeak: (messageId: string, text: string) => void;
|
||||||
@@ -51,13 +54,19 @@ type TurnListProps = {
|
|||||||
onRejectQuestion: (requestId: string) => void;
|
onRejectQuestion: (requestId: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
|
const STREAMING_NEAR_BOTTOM_THRESHOLD_PX = STREAMING_BOTTOM_RESERVE_PX + 120;
|
||||||
|
|
||||||
const sameMessages = (left: Message[], right: Message[]) =>
|
const sameMessages = (left: Message[], right: Message[]) =>
|
||||||
left.length === right.length &&
|
left.length === right.length &&
|
||||||
left.every((message, index) => message === right[index]);
|
left.every((message, index) => message === right[index]);
|
||||||
|
|
||||||
|
const TurnItem = React.memo(AgentTurn);
|
||||||
|
|
||||||
const TurnListInner = ({
|
const TurnListInner = ({
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
streamingMessageId,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
onSpeak,
|
onSpeak,
|
||||||
@@ -73,10 +82,10 @@ const TurnListInner = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{messages.map((message) => (
|
{messages.map((message) => (
|
||||||
<AgentTurn
|
<TurnItem
|
||||||
key={message.id}
|
key={message.id}
|
||||||
message={message}
|
message={message}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming && message.id === streamingMessageId}
|
||||||
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
messageSpeechState={speakingMessageId === message.id ? speechState : "idle"}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
onPause={onPauseSpeech}
|
onPause={onPauseSpeech}
|
||||||
@@ -98,6 +107,7 @@ const TurnList = React.memo(
|
|||||||
(prevProps, nextProps) =>
|
(prevProps, nextProps) =>
|
||||||
sameMessages(prevProps.messages, nextProps.messages) &&
|
sameMessages(prevProps.messages, nextProps.messages) &&
|
||||||
prevProps.isStreaming === nextProps.isStreaming &&
|
prevProps.isStreaming === nextProps.isStreaming &&
|
||||||
|
prevProps.streamingMessageId === nextProps.streamingMessageId &&
|
||||||
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
prevProps.speakingMessageId === nextProps.speakingMessageId &&
|
||||||
prevProps.speechState === nextProps.speechState &&
|
prevProps.speechState === nextProps.speechState &&
|
||||||
prevProps.onSpeak === nextProps.onSpeak &&
|
prevProps.onSpeak === nextProps.onSpeak &&
|
||||||
@@ -293,7 +303,9 @@ export const AgentWorkspace = ({
|
|||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
isLoadingSession = false,
|
isLoadingSession = false,
|
||||||
|
scrollContainerRef,
|
||||||
bottomRef,
|
bottomRef,
|
||||||
|
onScrollStateChange,
|
||||||
speakingMessageId,
|
speakingMessageId,
|
||||||
speechState,
|
speechState,
|
||||||
onSpeak,
|
onSpeak,
|
||||||
@@ -319,11 +331,24 @@ export const AgentWorkspace = ({
|
|||||||
isStreaming && messages.at(-1)?.role === "assistant"
|
isStreaming && messages.at(-1)?.role === "assistant"
|
||||||
? messages.at(-1)
|
? messages.at(-1)
|
||||||
: undefined;
|
: undefined;
|
||||||
const historyMessages =
|
const handleScroll = React.useCallback(
|
||||||
streamingMessage !== undefined ? messages.slice(0, -1) : messages;
|
(event: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
if (!onScrollStateChange) return;
|
||||||
|
const target = event.currentTarget;
|
||||||
|
const distanceToBottom =
|
||||||
|
target.scrollHeight - target.scrollTop - target.clientHeight;
|
||||||
|
onScrollStateChange(
|
||||||
|
distanceToBottom <
|
||||||
|
(isStreaming ? STREAMING_NEAR_BOTTOM_THRESHOLD_PX : 96),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[isStreaming, onScrollStateChange],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
onScroll={handleScroll}
|
||||||
sx={{
|
sx={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
@@ -331,6 +356,7 @@ export const AgentWorkspace = ({
|
|||||||
py: 2,
|
py: 2,
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
|
scrollbarGutter: "stable",
|
||||||
zIndex: 5,
|
zIndex: 5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -345,8 +371,9 @@ export const AgentWorkspace = ({
|
|||||||
{messages.length > 0 ? (
|
{messages.length > 0 ? (
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
||||||
<TurnList
|
<TurnList
|
||||||
messages={historyMessages}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
|
streamingMessageId={streamingMessage?.id ?? null}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
onSpeak={onSpeak}
|
onSpeak={onSpeak}
|
||||||
@@ -359,24 +386,6 @@ export const AgentWorkspace = ({
|
|||||||
onReplyQuestion={onReplyQuestion}
|
onReplyQuestion={onReplyQuestion}
|
||||||
onRejectQuestion={onRejectQuestion}
|
onRejectQuestion={onRejectQuestion}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{streamingMessage ? (
|
|
||||||
<TurnList
|
|
||||||
messages={[streamingMessage]}
|
|
||||||
isStreaming={isStreaming}
|
|
||||||
speakingMessageId={speakingMessageId}
|
|
||||||
speechState={speechState}
|
|
||||||
onSpeak={onSpeak}
|
|
||||||
onPauseSpeech={onPauseSpeech}
|
|
||||||
onResumeSpeech={onResumeSpeech}
|
|
||||||
onStopSpeech={onStopSpeech}
|
|
||||||
isTtsSupported={isTtsSupported}
|
|
||||||
onCreateBranch={onCreateBranch}
|
|
||||||
onReplyPermission={onReplyPermission}
|
|
||||||
onReplyQuestion={onReplyQuestion}
|
|
||||||
onRejectQuestion={onRejectQuestion}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Box>
|
</Box>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
@@ -403,7 +412,13 @@ export const AgentWorkspace = ({
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div ref={bottomRef} style={{ height: 1 }} />
|
<div
|
||||||
|
ref={bottomRef}
|
||||||
|
style={{
|
||||||
|
flexShrink: 0,
|
||||||
|
height: isStreaming ? STREAMING_BOTTOM_RESERVE_PX : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import ReactECharts from "echarts-for-react";
|
import ReactECharts from "echarts-for-react";
|
||||||
import * as echarts from "echarts";
|
import * as echarts from "echarts";
|
||||||
import { Box, Paper, Typography, alpha, useTheme } from "@mui/material";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { Box, Paper, Skeleton, Stack, Typography, alpha, useTheme } from "@mui/material";
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Inline chart rendered inside a chat message bubble. */
|
/* Inline chart rendered inside a chat message bubble. */
|
||||||
@@ -47,8 +48,12 @@ export interface ChatInlineChartProps {
|
|||||||
series?: unknown;
|
series?: unknown;
|
||||||
y_axis_name?: string;
|
y_axis_name?: string;
|
||||||
x_axis_name?: string;
|
x_axis_name?: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CHART_HEIGHT = 240;
|
||||||
|
export const CHART_MIN_HEIGHT = 286;
|
||||||
|
|
||||||
const COLORS = [
|
const COLORS = [
|
||||||
"#5470c6",
|
"#5470c6",
|
||||||
"#91cc75",
|
"#91cc75",
|
||||||
@@ -61,6 +66,49 @@ const COLORS = [
|
|||||||
"#ea7ccc",
|
"#ea7ccc",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const ChartSkeletonContent = ({ status }: { status?: React.ReactNode }) => (
|
||||||
|
<Stack spacing={1.25} sx={{ p: 1.5 }}>
|
||||||
|
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||||
|
<Skeleton variant="text" width="34%" height={20} />
|
||||||
|
{status}
|
||||||
|
</Stack>
|
||||||
|
<Skeleton variant="rounded" height={208} sx={{ borderRadius: 2 }} />
|
||||||
|
<Stack direction="row" spacing={1}>
|
||||||
|
<Skeleton variant="text" width="24%" height={16} />
|
||||||
|
<Skeleton variant="text" width="18%" height={16} />
|
||||||
|
<Skeleton variant="text" width="20%" height={16} />
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChartGenerationSkeleton = ({ status }: { status?: React.ReactNode }) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.18 }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.78),
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChartSkeletonContent status={status} />
|
||||||
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const toFiniteNumber = (value: unknown): number | null => {
|
const toFiniteNumber = (value: unknown): number | null => {
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return Number.isFinite(value) ? value : null;
|
return Number.isFinite(value) ? value : null;
|
||||||
@@ -189,13 +237,23 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
series,
|
series,
|
||||||
y_axis_name: yAxisName,
|
y_axis_name: yAxisName,
|
||||||
x_axis_name: xAxisName,
|
x_axis_name: xAxisName,
|
||||||
|
isStreaming = false,
|
||||||
}) => {
|
}) => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const [showIntroSkeleton, setShowIntroSkeleton] = React.useState(true);
|
||||||
const { xData, series: chartSeries } = useMemo(
|
const { xData, series: chartSeries } = useMemo(
|
||||||
() => normalizeChartData(x_data, series),
|
() => normalizeChartData(x_data, series),
|
||||||
[x_data, series],
|
[x_data, series],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
setShowIntroSkeleton(false);
|
||||||
|
}, isStreaming ? 360 : 260);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [isStreaming]);
|
||||||
|
|
||||||
const option = useMemo(() => {
|
const option = useMemo(() => {
|
||||||
if (!chartSeries.length) return null;
|
if (!chartSeries.length) return null;
|
||||||
|
|
||||||
@@ -208,6 +266,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
})) ?? [];
|
})) ?? [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
tooltip: { trigger: "item" },
|
tooltip: { trigger: "item" },
|
||||||
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
legend: { top: "bottom", textStyle: { fontSize: 11 } },
|
||||||
series: [
|
series: [
|
||||||
@@ -223,6 +286,10 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
label: { fontSize: 11 },
|
label: { fontSize: 11 },
|
||||||
|
animationType: "expansion",
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDelay: (idx: number) => idx * 40,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
color: COLORS,
|
color: COLORS,
|
||||||
@@ -231,6 +298,11 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
|
|
||||||
/* ---------- Line / Bar chart ---------- */
|
/* ---------- Line / Bar chart ---------- */
|
||||||
return {
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationEasing: "cubicOut",
|
||||||
|
animationEasingUpdate: "cubicOut",
|
||||||
tooltip: { trigger: "axis", confine: true },
|
tooltip: { trigger: "axis", confine: true },
|
||||||
legend: { top: "top", textStyle: { fontSize: 11 } },
|
legend: { top: "top", textStyle: { fontSize: 11 } },
|
||||||
grid: {
|
grid: {
|
||||||
@@ -262,14 +334,22 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
: undefined,
|
: undefined,
|
||||||
series: chartSeries.map((s, i) => {
|
series: chartSeries.map((s, i) => {
|
||||||
const color = COLORS[i % COLORS.length];
|
const color = COLORS[i % COLORS.length];
|
||||||
|
const isLineSeries = chartType === "line";
|
||||||
return {
|
return {
|
||||||
name: s.name,
|
name: s.name,
|
||||||
type: (s.type ?? chartType) as string,
|
type: (s.type ?? chartType) as string,
|
||||||
data: s.data,
|
data: s.data,
|
||||||
symbol: chartType === "line" ? "none" : undefined,
|
symbol: isLineSeries ? "none" : undefined,
|
||||||
smooth: chartType === "line",
|
smooth: isLineSeries,
|
||||||
itemStyle: { color },
|
itemStyle: { color },
|
||||||
...(chartType === "line"
|
animationDuration: isStreaming ? 560 : 420,
|
||||||
|
animationDurationUpdate: 240,
|
||||||
|
animationDelay:
|
||||||
|
chartType === "bar"
|
||||||
|
? (idx: number) => i * 80 + idx * 18
|
||||||
|
: i * 80,
|
||||||
|
animationDelayUpdate: 0,
|
||||||
|
...(isLineSeries
|
||||||
? {
|
? {
|
||||||
areaStyle: {
|
areaStyle: {
|
||||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||||
@@ -284,28 +364,73 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
}),
|
}),
|
||||||
color: COLORS,
|
color: COLORS,
|
||||||
};
|
};
|
||||||
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName]);
|
}, [chartType, xData, chartSeries, title, yAxisName, xAxisName, isStreaming]);
|
||||||
|
|
||||||
if (!option) {
|
if (!option) {
|
||||||
return (
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1 }}>
|
|
||||||
图表数据为空
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper
|
<Paper
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
mt: 1.5,
|
mt: 1.5,
|
||||||
mb: 1,
|
mb: 1,
|
||||||
|
minHeight: 72,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
px: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
border: `1px solid ${alpha(theme.palette.divider, 0.12)}`,
|
||||||
|
bgcolor: alpha("#fff", 0.72),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
图表数据为空
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
elevation={0}
|
||||||
|
sx={{
|
||||||
|
mt: 1.5,
|
||||||
|
mb: 1,
|
||||||
|
minHeight: CHART_MIN_HEIGHT,
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
border: `1px solid ${alpha(theme.palette.divider, 0.15)}`,
|
||||||
bgcolor: alpha("#fff", 0.92),
|
bgcolor: alpha("#fff", 0.92),
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<AnimatePresence initial={false}>
|
||||||
|
{showIntroSkeleton ? (
|
||||||
|
<Box
|
||||||
|
key="chart-intro-skeleton"
|
||||||
|
component={motion.div}
|
||||||
|
aria-hidden
|
||||||
|
initial={{ opacity: 1 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.22, ease: "easeOut" }}
|
||||||
|
sx={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 2,
|
||||||
|
bgcolor: alpha("#fff", 0.92),
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChartSkeletonContent />
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
{title && (
|
{title && (
|
||||||
<Typography
|
<Typography
|
||||||
variant="subtitle2"
|
variant="subtitle2"
|
||||||
@@ -314,14 +439,21 @@ export const ChatInlineChart: React.FC<ChatInlineChartProps> = ({
|
|||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
<Box sx={{ px: 1, pb: 1 }}>
|
<Box
|
||||||
|
component={motion.div}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: showIntroSkeleton ? 0.35 : 1 }}
|
||||||
|
transition={{ duration: 0.24, ease: "easeOut" }}
|
||||||
|
sx={{ px: 1, pb: 1, minHeight: CHART_HEIGHT }}
|
||||||
|
>
|
||||||
<ReactECharts
|
<ReactECharts
|
||||||
option={option}
|
option={option}
|
||||||
style={{ height: 240, width: "100%" }}
|
style={{ height: CHART_HEIGHT, width: "100%" }}
|
||||||
notMerge
|
notMerge
|
||||||
lazyUpdate
|
lazyUpdate
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Box, Drawer, alpha, useTheme } from "@mui/material";
|
|||||||
import { useNotification } from "@refinedev/core";
|
import { useNotification } from "@refinedev/core";
|
||||||
|
|
||||||
import { getAccessToken } from "@/lib/authToken";
|
import { getAccessToken } from "@/lib/authToken";
|
||||||
|
import { fetchAgentModels, type AgentModelOption } from "@/lib/chatModels";
|
||||||
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
import type { AgentApprovalMode, AgentModel } from "@/lib/chatStream";
|
||||||
import { useProjectStore } from "@/store/projectStore";
|
import { useProjectStore } from "@/store/projectStore";
|
||||||
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
import { AgentComposer, type AgentComposerHandle } from "./AgentComposer";
|
||||||
@@ -23,18 +24,23 @@ import { useSpeechRecognition, useSpeechSynthesis } from "./GlobalChatbox.voice"
|
|||||||
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
import { useAgentChatSession } from "./hooks/useAgentChatSession";
|
||||||
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
import { useAgentToolActions } from "./hooks/useAgentToolActions";
|
||||||
|
|
||||||
|
const STREAMING_BOTTOM_RESERVE_PX = 180;
|
||||||
|
const STREAMING_SCROLL_RESTORE_AT_PX = STREAMING_BOTTOM_RESERVE_PX - 36;
|
||||||
|
|
||||||
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
||||||
const [width, setWidth] = useState(520);
|
const [width, setWidth] = useState(520);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
||||||
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
const [isCheckingAuth, setIsCheckingAuth] = useState(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<AgentModel>(
|
const [modelOptions, setModelOptions] = useState<AgentModelOption[]>([]);
|
||||||
"deepseek/deepseek-v4-pro",
|
const [selectedModel, setSelectedModel] = useState<AgentModel | undefined>(undefined);
|
||||||
);
|
|
||||||
const [approvalMode, setApprovalMode] =
|
const [approvalMode, setApprovalMode] =
|
||||||
useState<AgentApprovalMode>("request");
|
useState<AgentApprovalMode>("request");
|
||||||
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const workspaceScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const isNearBottomRef = useRef(true);
|
||||||
|
const streamingScrollFrameRef = useRef<number | null>(null);
|
||||||
const composerRef = useRef<AgentComposerHandle | null>(null);
|
const composerRef = useRef<AgentComposerHandle | null>(null);
|
||||||
const hasResetForOpenRef = useRef(false);
|
const hasResetForOpenRef = useRef(false);
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
@@ -62,6 +68,36 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
isSupported: isSttSupported,
|
isSupported: isSttSupported,
|
||||||
} = useSpeechRecognition(handleSpeechResult);
|
} = useSpeechRecognition(handleSpeechResult);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const loadModels = async () => {
|
||||||
|
try {
|
||||||
|
const modelConfig = await fetchAgentModels();
|
||||||
|
if (cancelled) return;
|
||||||
|
setModelOptions(modelConfig.models);
|
||||||
|
setSelectedModel((current) => {
|
||||||
|
if (current && modelConfig.models.some((model) => model.id === current)) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
return modelConfig.defaultModel;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[GlobalChatbox] Failed to load agent models:", error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setModelOptions([]);
|
||||||
|
setSelectedModel(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadModels();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToolCall = useAgentToolActions();
|
const handleToolCall = useAgentToolActions();
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
@@ -93,9 +129,53 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
bottomRef.current?.scrollIntoView({ behavior });
|
bottomRef.current?.scrollIntoView({ behavior });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const cancelStreamingScroll = useCallback(() => {
|
||||||
|
if (streamingScrollFrameRef.current === null) return;
|
||||||
|
window.cancelAnimationFrame(streamingScrollFrameRef.current);
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const scheduleStreamingScrollToBottom = useCallback(() => {
|
||||||
|
if (streamingScrollFrameRef.current !== null) return;
|
||||||
|
streamingScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||||
|
streamingScrollFrameRef.current = null;
|
||||||
|
const container = workspaceScrollRef.current;
|
||||||
|
if (!container || !isNearBottomRef.current) return;
|
||||||
|
|
||||||
|
const distanceToBottom =
|
||||||
|
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||||
|
if (distanceToBottom < STREAMING_SCROLL_RESTORE_AT_PX) return;
|
||||||
|
|
||||||
|
container.scrollTop = container.scrollHeight - container.clientHeight;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleWorkspaceScrollStateChange = useCallback((isNearBottom: boolean) => {
|
||||||
|
isNearBottomRef.current = isNearBottom;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollToBottom(isStreaming ? "auto" : "smooth");
|
if (isStreaming) {
|
||||||
}, [isStreaming, messages, scrollToBottom]);
|
if (!isNearBottomRef.current) return;
|
||||||
|
scheduleStreamingScrollToBottom();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelStreamingScroll();
|
||||||
|
scrollToBottom("smooth");
|
||||||
|
}, [
|
||||||
|
cancelStreamingScroll,
|
||||||
|
isStreaming,
|
||||||
|
messages,
|
||||||
|
scheduleStreamingScrollToBottom,
|
||||||
|
scrollToBottom,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
cancelStreamingScroll();
|
||||||
|
},
|
||||||
|
[cancelStreamingScroll],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
@@ -110,10 +190,12 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
composerRef.current?.clear();
|
composerRef.current?.clear();
|
||||||
setIsHistoryOpen(false);
|
setIsHistoryOpen(false);
|
||||||
composerRef.current?.focus();
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
scrollToBottom("auto");
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [createSession, isHydrating, open, scrollToBottom]);
|
}, [cancelStreamingScroll, createSession, isHydrating, open, scrollToBottom]);
|
||||||
|
|
||||||
const handleSend = useCallback(async (prompt: string) => {
|
const handleSend = useCallback(async (prompt: string) => {
|
||||||
if (isStreaming || isCheckingAuth) return;
|
if (isStreaming || isCheckingAuth) return;
|
||||||
@@ -151,9 +233,11 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
composerRef.current?.clear();
|
composerRef.current?.clear();
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
composerRef.current?.focus();
|
composerRef.current?.focus();
|
||||||
|
isNearBottomRef.current = true;
|
||||||
|
cancelStreamingScroll();
|
||||||
scrollToBottom("auto");
|
scrollToBottom("auto");
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
}, [cancelStreamingScroll, createSession, handleStopSpeech, scrollToBottom, stopListening]);
|
||||||
|
|
||||||
const handleHistoryToggle = useCallback(() => {
|
const handleHistoryToggle = useCallback(() => {
|
||||||
setIsHistoryOpen((prev) => !prev);
|
setIsHistoryOpen((prev) => !prev);
|
||||||
@@ -347,7 +431,9 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
messages={messages}
|
messages={messages}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
isLoadingSession={Boolean(loadingSessionId)}
|
isLoadingSession={Boolean(loadingSessionId)}
|
||||||
|
scrollContainerRef={workspaceScrollRef}
|
||||||
bottomRef={bottomRef}
|
bottomRef={bottomRef}
|
||||||
|
onScrollStateChange={handleWorkspaceScrollStateChange}
|
||||||
speakingMessageId={speakingMessageId}
|
speakingMessageId={speakingMessageId}
|
||||||
speechState={speechState}
|
speechState={speechState}
|
||||||
onSpeak={handleSpeak}
|
onSpeak={handleSpeak}
|
||||||
@@ -372,6 +458,7 @@ export const GlobalChatbox: React.FC<Props> = ({ open, onClose }) => {
|
|||||||
onAbort={abort}
|
onAbort={abort}
|
||||||
onStartListening={startListening}
|
onStartListening={startListening}
|
||||||
onStopListening={stopListening}
|
onStopListening={stopListening}
|
||||||
|
modelOptions={modelOptions}
|
||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
approvalMode={approvalMode}
|
approvalMode={approvalMode}
|
||||||
|
|||||||
@@ -115,3 +115,8 @@
|
|||||||
color: var(--chat-md-quote-text);
|
color: var(--chat-md-quote-text);
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.streamFade {
|
||||||
|
box-decoration-break: clone;
|
||||||
|
-webkit-box-decoration-break: clone;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
createEmptyChatState,
|
createEmptyChatState,
|
||||||
saveActiveChatState,
|
deleteChatSession,
|
||||||
|
listChatSessions,
|
||||||
|
loadChatSessionById,
|
||||||
|
updateChatSessionTitle,
|
||||||
} from "./chatStorage";
|
} from "./chatStorage";
|
||||||
|
|
||||||
const apiFetch = jest.fn();
|
const apiFetch = jest.fn();
|
||||||
@@ -9,7 +12,7 @@ jest.mock("@/lib/apiFetch", () => ({
|
|||||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("chatStorage backend-only persistence", () => {
|
describe("chatStorage backend session operations", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
apiFetch.mockReset();
|
apiFetch.mockReset();
|
||||||
});
|
});
|
||||||
@@ -25,46 +28,106 @@ describe("chatStorage backend-only persistence", () => {
|
|||||||
expect(apiFetch).not.toHaveBeenCalled();
|
expect(apiFetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates a backend conversation when saving the first non-empty state", async () => {
|
it("lists backend sessions sorted by created time", async () => {
|
||||||
apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
|
apiFetch.mockResolvedValueOnce({
|
||||||
if (url.endsWith("/api/v1/agent/chat/session")) {
|
|
||||||
expect(init?.method).toBe("POST");
|
|
||||||
return {
|
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ session_id: "chat-new-1" }),
|
json: async () => ({
|
||||||
} as Response;
|
sessions: [
|
||||||
}
|
|
||||||
|
|
||||||
if (url.endsWith("/api/v1/agent/chat/session/chat-new-1")) {
|
|
||||||
expect(init?.method).toBe("PUT");
|
|
||||||
expect(JSON.parse(String(init?.body))).toMatchObject({
|
|
||||||
title: "新对话",
|
|
||||||
is_title_manually_edited: false,
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
json: async () => ({ id: "chat-new-1", session_id: "chat-new-1" }),
|
|
||||||
} as Response;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Unexpected request ${url}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedSessionId = await saveActiveChatState(
|
|
||||||
{
|
{
|
||||||
title: "新对话",
|
id: "session-old",
|
||||||
isTitleManuallyEdited: false,
|
title: "旧会话",
|
||||||
messages: [
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-02T00:00:00.000Z",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "message-2",
|
id: "session-new",
|
||||||
role: "user",
|
title: "新会话",
|
||||||
content: "第一条消息",
|
created_at: "2026-01-03T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-03T00:00:00.000Z",
|
||||||
|
is_streaming: true,
|
||||||
|
run_status: "running",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
sessionId: undefined,
|
}),
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
expect(savedSessionId).toBe("chat-new-1");
|
await expect(listChatSessions()).resolves.toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-new",
|
||||||
|
title: "新会话",
|
||||||
|
isStreaming: true,
|
||||||
|
runStatus: "running",
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
id: "session-old",
|
||||||
|
title: "旧会话",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads a backend session state", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
id: "session-1",
|
||||||
|
title: "管网分析",
|
||||||
|
is_title_manually_edited: true,
|
||||||
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
|
is_streaming: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(loadChatSessionById("session-1")).resolves.toMatchObject({
|
||||||
|
title: "管网分析",
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
sessionId: "session-1",
|
||||||
|
messages: [{ id: "message-1", role: "user", content: "查压力" }],
|
||||||
|
});
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "GET" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a backend session title through the title endpoint", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateChatSessionTitle("session-1", " 新标题 ", {
|
||||||
|
isTitleManuallyEdited: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(String(apiFetch.mock.calls[0][0])).toContain("/session/session-1/title");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "PATCH" });
|
||||||
|
expect(JSON.parse(String(apiFetch.mock.calls[0][1]?.body))).toEqual({
|
||||||
|
title: "新标题",
|
||||||
|
is_title_manually_edited: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a backend session and returns the next active session id", async () => {
|
||||||
|
apiFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
text: async () => "",
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
sessions: [
|
||||||
|
{
|
||||||
|
id: "session-next",
|
||||||
|
title: "下一会话",
|
||||||
|
created_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
updated_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(deleteChatSession("session-1")).resolves.toBe("session-next");
|
||||||
|
expect(apiFetch.mock.calls[0][1]).toMatchObject({ method: "DELETE" });
|
||||||
|
expect(apiFetch.mock.calls[1][1]).toMatchObject({ method: "GET" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,13 +27,6 @@ export const createEmptyChatState = (): LoadedChatState => ({
|
|||||||
const sanitizeMessages = (messages: Message[] | undefined) =>
|
const sanitizeMessages = (messages: Message[] | undefined) =>
|
||||||
Array.isArray(messages) ? cloneMessages(messages) : [];
|
Array.isArray(messages) ? cloneMessages(messages) : [];
|
||||||
|
|
||||||
const hasChatContent = (state: {
|
|
||||||
messages: Message[];
|
|
||||||
sessionId?: string;
|
|
||||||
}) =>
|
|
||||||
state.messages.length > 0 ||
|
|
||||||
Boolean(state.sessionId);
|
|
||||||
|
|
||||||
const compareSessionsByAnchorTime = (
|
const compareSessionsByAnchorTime = (
|
||||||
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
left: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
right: Pick<ChatSessionSummary, "id" | "createdAt" | "updatedAt">,
|
||||||
@@ -113,64 +106,6 @@ const fetchBackendChatSession = async (sessionId: string): Promise<LoadedChatSta
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const createBackendChatSession = async (payload?: {
|
|
||||||
sessionId?: string;
|
|
||||||
parentSessionId?: string;
|
|
||||||
}) => {
|
|
||||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/session`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
session_id: payload?.sessionId,
|
|
||||||
parent_session_id: payload?.parentSessionId,
|
|
||||||
}),
|
|
||||||
projectHeaderMode: "include",
|
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
const body = (await response.json()) as {
|
|
||||||
session_id?: string;
|
|
||||||
};
|
|
||||||
const sessionId = body.session_id?.trim();
|
|
||||||
if (!sessionId) {
|
|
||||||
throw new Error("backend did not return session_id");
|
|
||||||
}
|
|
||||||
return sessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveBackendChatState = async (
|
|
||||||
sessionId: string,
|
|
||||||
state: LoadedChatState,
|
|
||||||
): Promise<string> => {
|
|
||||||
const response = await apiFetch(
|
|
||||||
`${config.AGENT_URL}/api/v1/agent/chat/session/${encodeURIComponent(sessionId)}`,
|
|
||||||
{
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
title: normalizeTitle(state.title),
|
|
||||||
is_title_manually_edited: state.isTitleManuallyEdited ?? false,
|
|
||||||
messages: sanitizeMessages(state.messages),
|
|
||||||
}),
|
|
||||||
projectHeaderMode: "include",
|
|
||||||
userHeaderMode: "include",
|
|
||||||
skipAuthRedirect: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(await response.text());
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as { id?: string; session_id?: string };
|
|
||||||
return payload.id ?? payload.session_id ?? sessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateBackendChatSessionTitle = async (
|
const updateBackendChatSessionTitle = async (
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
title: string,
|
title: string,
|
||||||
@@ -212,27 +147,6 @@ const deleteBackendChatSession = async (sessionId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveActiveChatState = async (
|
|
||||||
state: LoadedChatState,
|
|
||||||
): Promise<string | undefined> => {
|
|
||||||
if (typeof window === "undefined") return state.sessionId;
|
|
||||||
|
|
||||||
if (!hasChatContent(state)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
let backendSessionId = state.sessionId;
|
|
||||||
if (!backendSessionId) {
|
|
||||||
backendSessionId = await createBackendChatSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedSessionId = await saveBackendChatState(backendSessionId, {
|
|
||||||
...state,
|
|
||||||
sessionId: backendSessionId,
|
|
||||||
});
|
|
||||||
return savedSessionId;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
export const listChatSessions = async (): Promise<ChatSessionSummary[]> => {
|
||||||
if (typeof window === "undefined") return [];
|
if (typeof window === "undefined") return [];
|
||||||
return await fetchBackendChatSessions();
|
return await fetchBackendChatSessions();
|
||||||
|
|||||||
@@ -7,19 +7,10 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
ChatProgress,
|
ChatProgress,
|
||||||
LoadedChatState,
|
|
||||||
Message,
|
Message,
|
||||||
} from "../GlobalChatbox.types";
|
} from "../GlobalChatbox.types";
|
||||||
import { createId } from "../GlobalChatbox.utils";
|
import { createId } from "../GlobalChatbox.utils";
|
||||||
|
|
||||||
export const createPersistedStateKey = (state: LoadedChatState) =>
|
|
||||||
JSON.stringify({
|
|
||||||
title: state.title ?? null,
|
|
||||||
isTitleManuallyEdited: state.isTitleManuallyEdited ?? false,
|
|
||||||
sessionId: state.sessionId ?? null,
|
|
||||||
messages: state.messages,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const upsertProgress = (
|
export const upsertProgress = (
|
||||||
progress: ChatProgress[] | undefined,
|
progress: ChatProgress[] | undefined,
|
||||||
event: StreamEvent & { type: "progress" },
|
event: StreamEvent & { type: "progress" },
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const saveActiveChatState = jest.fn();
|
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
jest.mock("../chatStorage", () => ({
|
jest.mock("../chatStorage", () => ({
|
||||||
@@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
|||||||
messages: [],
|
messages: [],
|
||||||
sessionId: "session-loaded",
|
sessionId: "session-loaded",
|
||||||
})),
|
})),
|
||||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
|
||||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,7 +48,6 @@ describe("useAgentChatSession", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listChatSessions.mockReset();
|
listChatSessions.mockReset();
|
||||||
deleteChatSession.mockReset();
|
deleteChatSession.mockReset();
|
||||||
saveActiveChatState.mockReset();
|
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
@@ -65,7 +62,6 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
deleteChatSession.mockImplementation(async () => undefined);
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
|
||||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ jest.mock("@/lib/chatStream", () => ({
|
|||||||
|
|
||||||
const listChatSessions = jest.fn();
|
const listChatSessions = jest.fn();
|
||||||
const deleteChatSession = jest.fn();
|
const deleteChatSession = jest.fn();
|
||||||
const saveActiveChatState = jest.fn();
|
|
||||||
const updateChatSessionTitle = jest.fn();
|
const updateChatSessionTitle = jest.fn();
|
||||||
|
|
||||||
jest.mock("../chatStorage", () => ({
|
jest.mock("../chatStorage", () => ({
|
||||||
@@ -42,7 +41,6 @@ jest.mock("../chatStorage", () => ({
|
|||||||
messages: [],
|
messages: [],
|
||||||
sessionId: "session-loaded",
|
sessionId: "session-loaded",
|
||||||
})),
|
})),
|
||||||
saveActiveChatState: (...args: unknown[]) => saveActiveChatState(...args),
|
|
||||||
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
updateChatSessionTitle: (...args: unknown[]) => updateChatSessionTitle(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -50,7 +48,6 @@ describe("useAgentChatSession", () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listChatSessions.mockReset();
|
listChatSessions.mockReset();
|
||||||
deleteChatSession.mockReset();
|
deleteChatSession.mockReset();
|
||||||
saveActiveChatState.mockReset();
|
|
||||||
updateChatSessionTitle.mockReset();
|
updateChatSessionTitle.mockReset();
|
||||||
jest.mocked(abortAgentChat).mockReset();
|
jest.mocked(abortAgentChat).mockReset();
|
||||||
jest.mocked(forkAgentChat).mockReset();
|
jest.mocked(forkAgentChat).mockReset();
|
||||||
@@ -65,7 +62,6 @@ describe("useAgentChatSession", () => {
|
|||||||
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
jest.mocked(resumeAgentChatStream).mockImplementation(async () => undefined);
|
||||||
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
jest.mocked(streamAgentChat).mockImplementation(async () => undefined);
|
||||||
deleteChatSession.mockImplementation(async () => undefined);
|
deleteChatSession.mockImplementation(async () => undefined);
|
||||||
saveActiveChatState.mockImplementation(async (state) => state.sessionId);
|
|
||||||
updateChatSessionTitle.mockImplementation(async () => undefined);
|
updateChatSessionTitle.mockImplementation(async () => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -190,7 +186,7 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists a new conversation only after the stream is done", async () => {
|
it("does not autosave full messages after the stream is done", async () => {
|
||||||
listChatSessions.mockResolvedValue([]);
|
listChatSessions.mockResolvedValue([]);
|
||||||
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
let emitStreamEvent: ((event: StreamEvent) => void) | undefined;
|
||||||
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
jest.mocked(streamAgentChat).mockImplementationOnce(async ({ onEvent }) => {
|
||||||
@@ -220,8 +216,6 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitStreamEvent?.({
|
emitStreamEvent?.({
|
||||||
type: "token",
|
type: "token",
|
||||||
@@ -234,8 +228,6 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(saveActiveChatState).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
emitStreamEvent?.({
|
emitStreamEvent?.({
|
||||||
type: "done",
|
type: "done",
|
||||||
@@ -247,14 +239,11 @@ describe("useAgentChatSession lifecycle and resume", () => {
|
|||||||
jest.advanceTimersByTime(200);
|
jest.advanceTimersByTime(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(saveActiveChatState).toHaveBeenCalledTimes(1));
|
expect(result.current.messages).toEqual([
|
||||||
expect(saveActiveChatState.mock.calls[0][0]).toMatchObject({
|
|
||||||
sessionId: "chat-stream-1",
|
|
||||||
messages: [
|
|
||||||
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
expect.objectContaining({ role: "user", content: "第一条消息" }),
|
||||||
expect.objectContaining({ role: "assistant", content: "收到" }),
|
expect.objectContaining({ role: "assistant", content: "收到" }),
|
||||||
],
|
]);
|
||||||
});
|
expect(result.current.activeSessionId).toBe("chat-stream-1");
|
||||||
} finally {
|
} finally {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,12 +4,75 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
import { abortAgentChat, forkAgentChat, rejectAgentQuestion, replyAgentPermission, replyAgentQuestion, resumeAgentChatStream, streamAgentChat } from "@/lib/chatStream";
|
||||||
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
import type { PermissionReply, StreamEvent } from "@/lib/chatStream";
|
||||||
import type { AgentArtifact, ChatSessionSummary, LoadedChatState, Message } from "../GlobalChatbox.types";
|
import type { AgentArtifact, ChatSessionSummary, Message } from "../GlobalChatbox.types";
|
||||||
import { cloneMessages } from "../GlobalChatbox.utils";
|
import { cloneMessages } from "../GlobalChatbox.utils";
|
||||||
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, saveActiveChatState, updateChatSessionTitle } from "../chatStorage";
|
import { createEmptyChatState, deleteChatSession, listChatSessions, loadChatSessionById, updateChatSessionTitle } from "../chatStorage";
|
||||||
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createPersistedStateKey, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
import { applyQuestionResponse, cancelRunningTodos, completeRunningProgress, createAssistantMessage, createTodoUpdateFromEvent, createUserMessage, dedupeQuestionsAcrossMessages, finalizeAssistantMessageAfterAbort, normalizeSessionTodos, toPermissionStatus, upsertPermission, upsertProgress, upsertQuestionAcrossMessages } from "./agentChatSessionState";
|
||||||
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
import type { PromptRunOptions, UseAgentChatSessionOptions } from "./useAgentChatSession.types";
|
||||||
|
|
||||||
|
const TOKEN_PLAYBACK_INTERVAL_MS = 16;
|
||||||
|
const TOKEN_PLAYBACK_BASE_CHARS = 28;
|
||||||
|
const TOKEN_PLAYBACK_MAX_CHARS = 160;
|
||||||
|
|
||||||
|
const sliceCodePoints = (value: string, count: number) =>
|
||||||
|
Array.from(value).slice(0, count).join("");
|
||||||
|
|
||||||
|
let cachedSegmenter: Intl.Segmenter | null | undefined;
|
||||||
|
|
||||||
|
const getSegmenter = () => {
|
||||||
|
if (cachedSegmenter !== undefined) return cachedSegmenter;
|
||||||
|
cachedSegmenter =
|
||||||
|
typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||||
|
? new Intl.Segmenter("zh", { granularity: "word" })
|
||||||
|
: null;
|
||||||
|
return cachedSegmenter;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlaybackChunkSize = (bufferLength: number) => {
|
||||||
|
if (bufferLength >= 600) return TOKEN_PLAYBACK_MAX_CHARS;
|
||||||
|
if (bufferLength >= 300) return 112;
|
||||||
|
if (bufferLength >= 140) return 72;
|
||||||
|
if (bufferLength >= 64) return 44;
|
||||||
|
return TOKEN_PLAYBACK_BASE_CHARS;
|
||||||
|
};
|
||||||
|
|
||||||
|
const takeNextTokenPlaybackChunk = (content: string, maxChars: number) => {
|
||||||
|
if (content.length <= maxChars) return content;
|
||||||
|
const targetChars = Math.max(12, Math.floor(maxChars * 0.68));
|
||||||
|
|
||||||
|
const segmenter = getSegmenter();
|
||||||
|
if (segmenter) {
|
||||||
|
let chunk = "";
|
||||||
|
for (const segment of segmenter.segment(content)) {
|
||||||
|
chunk += segment.segment;
|
||||||
|
if (
|
||||||
|
chunk.length >= maxChars ||
|
||||||
|
(chunk.length >= targetChars &&
|
||||||
|
/[\s,。!?、;:,.!?;:]/u.test(segment.segment))
|
||||||
|
) {
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const phrase = content.match(/^.{1,12}?[\s,。!?、;:,.!?;:]+/u)?.[0];
|
||||||
|
if (phrase) return phrase;
|
||||||
|
|
||||||
|
const cjkChunk = content.match(
|
||||||
|
/^[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u,
|
||||||
|
)?.[0];
|
||||||
|
if (cjkChunk) return sliceCodePoints(cjkChunk, Math.min(maxChars, 18));
|
||||||
|
|
||||||
|
const wordChunk = content.match(/^\S+\s*/u)?.[0];
|
||||||
|
if (wordChunk) {
|
||||||
|
return wordChunk.length <= maxChars
|
||||||
|
? wordChunk
|
||||||
|
: sliceCodePoints(wordChunk, maxChars);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sliceCodePoints(content, Math.min(maxChars, 12));
|
||||||
|
};
|
||||||
|
|
||||||
export const useAgentChatSession = ({
|
export const useAgentChatSession = ({
|
||||||
projectId,
|
projectId,
|
||||||
onToolCall,
|
onToolCall,
|
||||||
@@ -17,7 +80,6 @@ export const useAgentChatSession = ({
|
|||||||
getModel,
|
getModel,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
}: UseAgentChatSessionOptions) => {
|
}: UseAgentChatSessionOptions) => {
|
||||||
const hydrationCompletedRef = useRef(false);
|
|
||||||
const hydrationNonceRef = useRef(0);
|
const hydrationNonceRef = useRef(0);
|
||||||
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
@@ -35,14 +97,11 @@ export const useAgentChatSession = ({
|
|||||||
const isSessionTitleManuallyEditedRef = useRef(false);
|
const isSessionTitleManuallyEditedRef = useRef(false);
|
||||||
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
const cancelPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
const titleUpdateNonceRef = useRef(0);
|
const titleUpdateNonceRef = useRef(0);
|
||||||
const lastPersistedStateKeyRef = useRef(
|
const pendingTokenRef = useRef<{
|
||||||
createPersistedStateKey({
|
assistantMessageId: string;
|
||||||
sessionId: undefined,
|
content: string;
|
||||||
title: undefined,
|
} | null>(null);
|
||||||
isTitleManuallyEdited: false,
|
const tokenPlaybackIntervalRef = useRef<number | null>(null);
|
||||||
messages: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
sessionIdRef.current = sessionId;
|
sessionIdRef.current = sessionId;
|
||||||
@@ -52,6 +111,99 @@ export const useAgentChatSession = ({
|
|||||||
messagesRef.current = messages;
|
messagesRef.current = messages;
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
|
const applyTokenContent = useCallback((assistantMessageId: string, content: string) => {
|
||||||
|
if (!content) return;
|
||||||
|
setMessages((prev) => {
|
||||||
|
const next = prev.map((message) =>
|
||||||
|
message.id === assistantMessageId
|
||||||
|
? {
|
||||||
|
...message,
|
||||||
|
content: message.content + content,
|
||||||
|
isError: false,
|
||||||
|
}
|
||||||
|
: message,
|
||||||
|
);
|
||||||
|
messagesRef.current = next;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cancelTokenPlayback = useCallback(() => {
|
||||||
|
const intervalId = tokenPlaybackIntervalRef.current;
|
||||||
|
if (intervalId === null) return;
|
||||||
|
window.clearInterval(intervalId);
|
||||||
|
tokenPlaybackIntervalRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const flushPendingTokens = useCallback(() => {
|
||||||
|
const pending = pendingTokenRef.current;
|
||||||
|
pendingTokenRef.current = null;
|
||||||
|
cancelTokenPlayback();
|
||||||
|
if (!pending) return;
|
||||||
|
applyTokenContent(pending.assistantMessageId, pending.content);
|
||||||
|
}, [applyTokenContent, cancelTokenPlayback]);
|
||||||
|
|
||||||
|
const scheduleTokenPlayback = useCallback(() => {
|
||||||
|
if (tokenPlaybackIntervalRef.current !== null) return;
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
const pending = pendingTokenRef.current;
|
||||||
|
if (!pending) {
|
||||||
|
window.clearInterval(id);
|
||||||
|
tokenPlaybackIntervalRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = takeNextTokenPlaybackChunk(
|
||||||
|
pending.content,
|
||||||
|
getPlaybackChunkSize(pending.content.length),
|
||||||
|
);
|
||||||
|
if (!chunk) {
|
||||||
|
window.clearInterval(id);
|
||||||
|
tokenPlaybackIntervalRef.current = null;
|
||||||
|
pendingTokenRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = pending.content.slice(chunk.length);
|
||||||
|
pendingTokenRef.current = remaining
|
||||||
|
? { assistantMessageId: pending.assistantMessageId, content: remaining }
|
||||||
|
: null;
|
||||||
|
applyTokenContent(pending.assistantMessageId, chunk);
|
||||||
|
|
||||||
|
if (!remaining) {
|
||||||
|
window.clearInterval(id);
|
||||||
|
tokenPlaybackIntervalRef.current = null;
|
||||||
|
}
|
||||||
|
}, TOKEN_PLAYBACK_INTERVAL_MS);
|
||||||
|
tokenPlaybackIntervalRef.current = id;
|
||||||
|
}, [applyTokenContent]);
|
||||||
|
|
||||||
|
const queueTokenContent = useCallback(
|
||||||
|
(assistantMessageId: string, content: string) => {
|
||||||
|
const pending = pendingTokenRef.current;
|
||||||
|
if (pending && pending.assistantMessageId !== assistantMessageId) {
|
||||||
|
flushPendingTokens();
|
||||||
|
}
|
||||||
|
pendingTokenRef.current = {
|
||||||
|
assistantMessageId,
|
||||||
|
content:
|
||||||
|
pending?.assistantMessageId === assistantMessageId
|
||||||
|
? pending.content + content
|
||||||
|
: content,
|
||||||
|
};
|
||||||
|
scheduleTokenPlayback();
|
||||||
|
},
|
||||||
|
[flushPendingTokens, scheduleTokenPlayback],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
pendingTokenRef.current = null;
|
||||||
|
cancelTokenPlayback();
|
||||||
|
},
|
||||||
|
[cancelTokenPlayback],
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
isSessionTitleManuallyEditedRef.current = isSessionTitleManuallyEdited;
|
||||||
@@ -62,17 +214,9 @@ export const useAgentChatSession = ({
|
|||||||
|
|
||||||
const hydrate = async () => {
|
const hydrate = async () => {
|
||||||
setIsHydrating(true);
|
setIsHydrating(true);
|
||||||
hydrationCompletedRef.current = false;
|
|
||||||
|
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
hydrationCompletedRef.current = true;
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
@@ -93,8 +237,6 @@ export const useAgentChatSession = ({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
sessionIdRef.current = loadedState.sessionId;
|
sessionIdRef.current = loadedState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(loadedState);
|
|
||||||
hydrationCompletedRef.current = true;
|
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
|
|
||||||
@@ -127,51 +269,6 @@ export const useAgentChatSession = ({
|
|||||||
};
|
};
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!projectId || isHydrating || !hydrationCompletedRef.current) return;
|
|
||||||
|
|
||||||
const currentHydrationNonce = hydrationNonceRef.current;
|
|
||||||
const persistTimer = window.setTimeout(() => {
|
|
||||||
if (isStreaming) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state: LoadedChatState = {
|
|
||||||
title: sessionTitle,
|
|
||||||
isTitleManuallyEdited: isSessionTitleManuallyEdited,
|
|
||||||
messages,
|
|
||||||
sessionId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentStateKey = createPersistedStateKey(state);
|
|
||||||
if (currentStateKey === lastPersistedStateKeyRef.current) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void saveActiveChatState(state)
|
|
||||||
.then((sessionId) => {
|
|
||||||
if (hydrationNonceRef.current !== currentHydrationNonce) return;
|
|
||||||
sessionIdRef.current = sessionId;
|
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
...state,
|
|
||||||
sessionId,
|
|
||||||
});
|
|
||||||
return listChatSessions();
|
|
||||||
})
|
|
||||||
.then((sessions) => {
|
|
||||||
if (!sessions || hydrationNonceRef.current !== currentHydrationNonce) return;
|
|
||||||
setChatSessions(sessions);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("[GlobalChatbox] Failed to persist chat state:", error);
|
|
||||||
});
|
|
||||||
}, 150);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.clearTimeout(persistTimer);
|
|
||||||
};
|
|
||||||
}, [isHydrating, isSessionTitleManuallyEdited, isStreaming, messages, projectId, sessionId, sessionTitle]);
|
|
||||||
|
|
||||||
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
const appendArtifact = useCallback((messageId: string, artifact: AgentArtifact) => {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -199,6 +296,10 @@ export const useAgentChatSession = ({
|
|||||||
assistantMessageId?: string;
|
assistantMessageId?: string;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
|
if (event.type !== "token") {
|
||||||
|
flushPendingTokens();
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.type !== "session_title" &&
|
event.type !== "session_title" &&
|
||||||
"sessionId" in event &&
|
"sessionId" in event &&
|
||||||
@@ -226,12 +327,6 @@ export const useAgentChatSession = ({
|
|||||||
const targetSessionId = event.sessionId || currentSessionId;
|
const targetSessionId = event.sessionId || currentSessionId;
|
||||||
if (targetSessionId === currentSessionId) {
|
if (targetSessionId === currentSessionId) {
|
||||||
setSessionTitle(nextTitle);
|
setSessionTitle(nextTitle);
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
sessionId: targetSessionId,
|
|
||||||
title: nextTitle,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: messagesRef.current,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (targetSessionId) {
|
if (targetSessionId) {
|
||||||
const currentNonce = ++titleUpdateNonceRef.current;
|
const currentNonce = ++titleUpdateNonceRef.current;
|
||||||
@@ -257,17 +352,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "token") {
|
if (event.type === "token") {
|
||||||
setMessages((prev) =>
|
queueTokenContent(assistantMessageId, event.content);
|
||||||
prev.map((message) =>
|
|
||||||
message.id === assistantMessageId
|
|
||||||
? {
|
|
||||||
...message,
|
|
||||||
content: message.content + event.content,
|
|
||||||
isError: false,
|
|
||||||
}
|
|
||||||
: message,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (event.type === "progress") {
|
} else if (event.type === "progress") {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev.map((message) =>
|
prev.map((message) =>
|
||||||
@@ -373,7 +458,13 @@ export const useAgentChatSession = ({
|
|||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appendArtifact, getLastAssistantMessageId, onToolCall],
|
[
|
||||||
|
appendArtifact,
|
||||||
|
flushPendingTokens,
|
||||||
|
getLastAssistantMessageId,
|
||||||
|
onToolCall,
|
||||||
|
queueTokenContent,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resumeStreamingSession = useCallback(
|
const resumeStreamingSession = useCallback(
|
||||||
@@ -389,18 +480,20 @@ export const useAgentChatSession = ({
|
|||||||
onEvent: (event) => applyStreamEvent(event),
|
onEvent: (event) => applyStreamEvent(event),
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
|
flushPendingTokens();
|
||||||
if (!controller.signal.aborted) {
|
if (!controller.signal.aborted) {
|
||||||
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
console.error("[GlobalChatbox] Failed to resume chat stream:", error);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
flushPendingTokens();
|
||||||
if (abortRef.current === controller) {
|
if (abortRef.current === controller) {
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[applyStreamEvent],
|
[applyStreamEvent, flushPendingTokens],
|
||||||
);
|
);
|
||||||
resumeStreamingSessionRef.current = resumeStreamingSession;
|
resumeStreamingSessionRef.current = resumeStreamingSession;
|
||||||
|
|
||||||
@@ -449,6 +542,7 @@ export const useAgentChatSession = ({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
flushPendingTokens();
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
prev
|
prev
|
||||||
@@ -485,12 +579,14 @@ export const useAgentChatSession = ({
|
|||||||
);
|
);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
} finally {
|
} finally {
|
||||||
|
flushPendingTokens();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
applyStreamEvent,
|
applyStreamEvent,
|
||||||
|
flushPendingTokens,
|
||||||
getApprovalMode,
|
getApprovalMode,
|
||||||
getModel,
|
getModel,
|
||||||
isHydrating,
|
isHydrating,
|
||||||
@@ -503,6 +599,7 @@ export const useAgentChatSession = ({
|
|||||||
const abort = useCallback(() => {
|
const abort = useCallback(() => {
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
|
flushPendingTokens();
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
const assistantMessageId = getLastAssistantMessageId();
|
const assistantMessageId = getLastAssistantMessageId();
|
||||||
|
|
||||||
@@ -525,7 +622,7 @@ export const useAgentChatSession = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
cancelPromiseRef.current = trackedCancelPromise;
|
cancelPromiseRef.current = trackedCancelPromise;
|
||||||
}, [getLastAssistantMessageId]);
|
}, [flushPendingTokens, getLastAssistantMessageId]);
|
||||||
|
|
||||||
const replyPermission = useCallback(
|
const replyPermission = useCallback(
|
||||||
async (requestId: string, reply: PermissionReply) => {
|
async (requestId: string, reply: PermissionReply) => {
|
||||||
@@ -738,23 +835,18 @@ export const useAgentChatSession = ({
|
|||||||
const createSession = useCallback(() => {
|
const createSession = useCallback(() => {
|
||||||
if (isHydrating || isStreaming) return;
|
if (isHydrating || isStreaming) return;
|
||||||
|
|
||||||
|
flushPendingTokens();
|
||||||
const controller = abortRef.current;
|
const controller = abortRef.current;
|
||||||
controller?.abort();
|
controller?.abort();
|
||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: "新对话",
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle("新对话");
|
setSessionTitle("新对话");
|
||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
setSessionId(undefined);
|
setSessionId(undefined);
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
}, [isHydrating, isStreaming]);
|
}, [flushPendingTokens, isHydrating, isStreaming]);
|
||||||
|
|
||||||
const switchSession = useCallback(
|
const switchSession = useCallback(
|
||||||
async (nextSessionId: string, optimisticTitle?: string) => {
|
async (nextSessionId: string, optimisticTitle?: string) => {
|
||||||
@@ -777,7 +869,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
@@ -821,12 +912,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = undefined;
|
sessionIdRef.current = undefined;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
title: undefined,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: [],
|
|
||||||
sessionId: undefined,
|
|
||||||
});
|
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setSessionTitle(undefined);
|
setSessionTitle(undefined);
|
||||||
setIsSessionTitleManuallyEdited(false);
|
setIsSessionTitleManuallyEdited(false);
|
||||||
@@ -842,7 +927,6 @@ export const useAgentChatSession = ({
|
|||||||
hydrationNonceRef.current += 1;
|
hydrationNonceRef.current += 1;
|
||||||
titleUpdateNonceRef.current += 1;
|
titleUpdateNonceRef.current += 1;
|
||||||
sessionIdRef.current = nextState.sessionId;
|
sessionIdRef.current = nextState.sessionId;
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey(nextState);
|
|
||||||
setMessages(nextState.messages);
|
setMessages(nextState.messages);
|
||||||
setSessionTitle(nextState.title);
|
setSessionTitle(nextState.title);
|
||||||
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
setIsSessionTitleManuallyEdited(nextState.isTitleManuallyEdited ?? false);
|
||||||
@@ -884,18 +968,12 @@ export const useAgentChatSession = ({
|
|||||||
if (sessionIdRef.current === targetSessionId) {
|
if (sessionIdRef.current === targetSessionId) {
|
||||||
setSessionTitle(normalizedTitle);
|
setSessionTitle(normalizedTitle);
|
||||||
setIsSessionTitleManuallyEdited(true);
|
setIsSessionTitleManuallyEdited(true);
|
||||||
lastPersistedStateKeyRef.current = createPersistedStateKey({
|
|
||||||
sessionId: targetSessionId,
|
|
||||||
title: normalizedTitle,
|
|
||||||
isTitleManuallyEdited: true,
|
|
||||||
messages,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
console.error("[GlobalChatbox] Failed to rename chat session:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isHydrating, messages],
|
[isHydrating],
|
||||||
);
|
);
|
||||||
|
|
||||||
const createBranch = useCallback(
|
const createBranch = useCallback(
|
||||||
@@ -920,12 +998,6 @@ export const useAgentChatSession = ({
|
|||||||
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
const forkTitle = sessionTitle ? `${sessionTitle} 副本` : "新对话副本";
|
||||||
setSessionTitle(forkTitle);
|
setSessionTitle(forkTitle);
|
||||||
try {
|
try {
|
||||||
await saveActiveChatState({
|
|
||||||
title: forkTitle,
|
|
||||||
isTitleManuallyEdited: false,
|
|
||||||
messages: copiedMessages,
|
|
||||||
sessionId: forkedSessionId,
|
|
||||||
});
|
|
||||||
setChatSessions(await listChatSessions());
|
setChatSessions(await listChatSessions());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
console.error("[GlobalChatbox] Failed to refresh chat sessions after fork:", error);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type UseAgentChatSessionOptions = {
|
|||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
onBeforeSend?: () => void;
|
onBeforeSend?: () => void;
|
||||||
getModel?: () => AgentModel;
|
getModel?: () => AgentModel | undefined;
|
||||||
getApprovalMode?: () => AgentApprovalMode;
|
getApprovalMode?: () => AgentApprovalMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { fetchAgentModels } from "./chatModels";
|
||||||
|
|
||||||
|
const apiFetch = jest.fn();
|
||||||
|
|
||||||
|
jest.mock("@/lib/apiFetch", () => ({
|
||||||
|
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("fetchAgentModels", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
apiFetch.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("loads model options and backend default model", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
default_model: "deepseek/deepseek-v4-flash",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
label: "快速",
|
||||||
|
description: "快速回答和任务执行",
|
||||||
|
icon: "bolt",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchAgentModels()).resolves.toEqual({
|
||||||
|
defaultModel: "deepseek/deepseek-v4-flash",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "deepseek/deepseek-v4-flash",
|
||||||
|
label: "快速",
|
||||||
|
description: "快速回答和任务执行",
|
||||||
|
icon: "bolt",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(apiFetch).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining("/api/v1/agent/chat/models"),
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the first option when default model is omitted", async () => {
|
||||||
|
apiFetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
models: [{ id: "provider/model", label: "Model" }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(fetchAgentModels()).resolves.toEqual({
|
||||||
|
defaultModel: "provider/model",
|
||||||
|
models: [{ id: "provider/model", label: "Model" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
|
import { config } from "@config/config";
|
||||||
|
|
||||||
|
import type { AgentModel } from "./chatStream";
|
||||||
|
|
||||||
|
export type AgentModelIcon = "bolt" | "sparkle";
|
||||||
|
|
||||||
|
export type AgentModelOption = {
|
||||||
|
id: AgentModel;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: AgentModelIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentModelConfig = {
|
||||||
|
defaultModel?: AgentModel;
|
||||||
|
models: AgentModelOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
const normalizeModelOption = (value: unknown): AgentModelOption | null => {
|
||||||
|
if (!isObjectRecord(value) || typeof value.id !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const id = value.id.trim();
|
||||||
|
if (!id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
typeof value.label === "string" && value.label.trim()
|
||||||
|
? value.label.trim()
|
||||||
|
: id;
|
||||||
|
const description =
|
||||||
|
typeof value.description === "string" && value.description.trim()
|
||||||
|
? value.description.trim()
|
||||||
|
: undefined;
|
||||||
|
const icon =
|
||||||
|
value.icon === "bolt" || value.icon === "sparkle" ? value.icon : undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchAgentModels = async (): Promise<AgentModelConfig> => {
|
||||||
|
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/models`, {
|
||||||
|
method: "GET",
|
||||||
|
projectHeaderMode: "include",
|
||||||
|
userHeaderMode: "include",
|
||||||
|
skipAuthRedirect: true,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
default_model?: unknown;
|
||||||
|
models?: unknown[];
|
||||||
|
};
|
||||||
|
const models = (payload.models ?? [])
|
||||||
|
.map(normalizeModelOption)
|
||||||
|
.filter((model): model is AgentModelOption => Boolean(model));
|
||||||
|
const defaultModel =
|
||||||
|
typeof payload.default_model === "string" && payload.default_model.trim()
|
||||||
|
? payload.default_model.trim()
|
||||||
|
: models[0]?.id;
|
||||||
|
return {
|
||||||
|
defaultModel,
|
||||||
|
models,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -60,7 +60,7 @@ describe("streamAgentChat", () => {
|
|||||||
|
|
||||||
await streamAgentChat({
|
await streamAgentChat({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
model: "deepseek/deepseek-v4-pro",
|
model: "provider/model",
|
||||||
onEvent: (event) => events.push(event),
|
onEvent: (event) => events.push(event),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ describe("streamAgentChat", () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
message: "hi",
|
message: "hi",
|
||||||
session_id: undefined,
|
session_id: undefined,
|
||||||
model: "deepseek/deepseek-v4-pro",
|
model: "provider/model",
|
||||||
approval_mode: undefined,
|
approval_mode: undefined,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { apiFetch } from "@/lib/apiFetch";
|
import { apiFetch } from "@/lib/apiFetch";
|
||||||
import { config } from "@config/config";
|
import { config } from "@config/config";
|
||||||
|
|
||||||
export type AgentModel =
|
export type AgentModel = string;
|
||||||
| "deepseek/deepseek-v4-flash"
|
|
||||||
| "deepseek/deepseek-v4-pro";
|
|
||||||
|
|
||||||
export type PermissionReply = "once" | "always" | "reject";
|
export type PermissionReply = "once" | "always" | "reject";
|
||||||
export type AgentApprovalMode = "request" | "always";
|
export type AgentApprovalMode = "request" | "always";
|
||||||
|
|||||||
Reference in New Issue
Block a user