feat: 支持点击图片直接聚焦
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 2m42s
Project CI / Native shell tests (pull_request) Successful in 12m8s

重构toolcall status等, 用switch代替嵌套的三元表达式
This commit is contained in:
2026-07-31 15:26:59 +08:00
parent ff870d0cb1
commit 8706e6d9af
6 changed files with 89 additions and 43 deletions
@@ -38,6 +38,16 @@
---
## 2026-07-31 画布 Agent 图片结果单击直接定位
- 背景:生成图片在对话中是静态缩略图,用户需要用更直接的方式回到对应画布图层;视频和音频仍有播放、拖动和音量等原生点击交互,不能共用该行为。
- 决策:携带有效 `resourceId` 的 Agent 生成图片在普通单击时直接调用实例级 `ImageCanvasActionsContext.focusResource(resourceId)`;无 `resourceId` 的旧图片保持无动作。视频和音频的普通点击仍只操作播放器,三类媒体均保留右键菜单的“在画布中定位”。图片卡片本次不新增按钮语义或键盘 Tab 停靠,Enter / Space 不触发定位。
- 影响范围:`ToolCallView`、消息气泡交互测试和画布 Agent 前端专题文档;不修改共享 DTO、后端 API、SpacetimeDB 或 viewport 动画语义。
- 验证方式:覆盖有效图片单击、旧图片无动作、视频 / 音频单击无定位及三类媒体右键定位;运行前端定向测试、`npm run typecheck`、`npm run lint:eslint`、`npm run check:encoding` 和 `git diff --check`。
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
---
## 2026-07-30 画布 Agent 结果通过实例级 Action Context 刷新并从右键菜单定位资源
- 背景:画布 Agent 图片、视频和音频结果需要提供画布定位和结果完成后的工程刷新;若继续从舞台向面板、消息和工具结果逐层传 callback,会扩大现有 prop drilling,而把 callback 或瞬时命令放入全局 Zustand 又会引入多实例和卸载残留问题。直接把点击和定位语义附到生成媒体上还会让视频 / 音频的播放、暂停、拖动与音量操作误触发画布聚焦,并给原生媒体控件附加错误的定位标签。
@@ -153,7 +153,7 @@
- Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。
- Agent 输入支持文本消息和带附件的文本消息;不支持纯附件消息。附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。
- 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。
- Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”,并通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层;普通媒体卡片不声明按钮语义或 `tabIndex`,点击、Enter 和 Space 均不得触发定位,视频和音频的原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。
- Agent 工具任务完成并懒回填后,消息内缩略图不显示名称;前端通过编辑器作用域 Action Context 的 `refreshCanvas()` 直接重新读取工程快照和素材库,不从 Editor 经 Stage、Panel 和 MessageBubble 透传刷新 callback。图片、视频和音频结果携带有效 `resourceId` 时,在素材右键菜单显示“在画布中定位”;有效图片结果的普通单击也直接通过同一 Context 的 `focusResource(resourceId)` 请求画布在 `420ms` 内平滑 fit 到对应图层。结果卡片不声明按钮语义或 `tabIndex`,Enter 和 Space 不得触发定位;视频和音频的普通点击及原生播放器交互保持独立。定位只改变 viewport,不选择图层、不切换工具或侧栏、不收起 Agent 面板,也不避让面板覆盖区。缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。
- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
@@ -50,7 +50,7 @@
## 生成结果落画板(对现有占位规则的例外)
- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。
- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。图片、视频或音频结果携带有效 `resourceId` 时,右键素材后通过“在画布中定位”调用同一 Context 的 `focusResource(resourceId)`,请求画布视口在 `420ms` 内平滑 fit 到对应图层;普通媒体卡片不承载按钮语义、不进入键盘 Tab 顺序,普通点击以及 Enter / Space 均不触发画布聚焦,视频和音频的播放、暂停、拖动、音量等原生控制只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。
- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态或收到已经包含结果媒体的会话增量时,直接通过编辑器作用域 `ImageCanvasActionsContext.refreshCanvas()` 重新读取工程快照并刷新素材库,不经过 Stage、Agent 面板或消息组件逐层透传 callback。重新读取会话后,以回填的轻量媒体引用显示缩略图。携带有效 `resourceId` 的图片结果在普通单击时直接调用同一 Context 的 `focusResource(resourceId)`;图片、视频或音频结果仍可从右键素材菜单选择“在画布中定位”。两种入口都请求画布视口在 `420ms` 内平滑 fit 到对应图层;结果卡片不进入键盘 Tab 顺序,Enter / Space 不触发画布聚焦,视频和音频的点击、播放、暂停、拖动、音量等原生交互只操作播放器。定位不选中图层、不切换工具或侧栏、不收起 Agent 面板,也不为 Agent 面板预留可见区域。
- 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 `<img>`。
- 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。
- 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。
@@ -126,7 +126,7 @@
1. 助手文本随普通 JSON 消息响应一次性返回;
2. 消息请求等待态,以及工具任务的待确认、生成中、完成 / 失败状态;
3. 工具/模型标注行(生成时显示模型名 + 图标);
4. 消息内生成结果缩略图不显示名称;携带有效 `resourceId` 时可从素材右键菜单选择“在画布中定位”,以平滑视口动画定位对应画布图层;普通点击只保留媒体自身交互;
4. 消息内生成结果缩略图不显示名称;携带有效 `resourceId` 的图片可单击定位,图片、视频和音频都可从素材右键菜单选择“在画布中定位”,以平滑视口动画定位对应画布图层;视频和音频的普通点击只保留媒体自身交互;
5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,超过 120 秒但 POST 仍 pending 时在思考气泡中显示“仍在处理中,请耐心等待”,最终成功或失败后自动移除,避免后端已持久化消息但前端中断请求后产生会话状态错位。
@@ -172,6 +172,6 @@
- 打开画布 Agent 后任务侧栏应关闭,再次打开任务侧栏时 Agent 面板应关闭;素材 / 图层面板与 Agent 可同时展开,互不改写开关状态。
- 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。
- Agent 消息内生成结果缩略图不显示名称;轮询到任务终态并完成会话懒回填后,通过实例级 `refreshCanvas()` 统一刷新工程快照和素材库,禁止重新引入中间刷新 props。只允许从有效结果的素材右键菜单触发画布定位,普通点击以及视频 / 音频原生播放器操作不得触发;定位时必须保持 Agent 面板、当前图层选择、工具和侧栏不变,只平滑调整 viewport 到对应图层;旧消息缺少 `resourceId` 或目标图层已删除时不显示定位菜单项。
- Agent 消息内生成结果缩略图不显示名称;轮询到任务终态并完成会话懒回填后,通过实例级 `refreshCanvas()` 统一刷新工程快照和素材库,禁止重新引入中间刷新 props。有效图片结果的普通单击和三类媒体的“在画布中定位”右键菜单项都可触发画布定位;视频 / 音频普通点击与原生播放器操作不得触发。定位时必须保持 Agent 面板、当前图层选择、工具和侧栏不变,只平滑调整 viewport 到对应图层;旧消息缺少 `resourceId` 时单击无动作且不显示定位菜单项,目标图层已删除时保持无动作。
- 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。
- 对话正文右键菜单只复制当前气泡展示的完整文本,隐藏的内部 system 文本不得进入菜单;素材右键菜单优先于正文菜单,私有素材继续通过既有读取链路换签或代理下载,不复制会过期的临时链接。
@@ -387,7 +387,7 @@ describe('MessageBubble', () => {
expect(screen.getByRole('menuitem', { name: '下载图片' })).toBeTruthy();
});
it('focuses generated image, video and audio only from their right-click menus', async () => {
it('focuses generated images on click and all generated media from their right-click menus', async () => {
const { container } = renderMessage({
id: 9,
role: 'system',
@@ -431,7 +431,8 @@ describe('MessageBubble', () => {
fireEvent.click(imageCard!);
fireEvent.click(video!);
fireEvent.click(audio!);
expect(focusResourceMock).not.toHaveBeenCalled();
expect(focusResourceMock).toHaveBeenCalledTimes(1);
expect(focusResourceMock).toHaveBeenCalledWith('resource-image');
const targets = [
[imageCard, 'resource-image'],
@@ -448,7 +449,7 @@ describe('MessageBubble', () => {
).toBeNull(),
);
}
expect(focusResourceMock).toHaveBeenCalledTimes(3);
expect(focusResourceMock).toHaveBeenCalledTimes(4);
focusResourceMock.mockReturnValueOnce({ found: false });
fireEvent.contextMenu(imageCard!, { clientX: 30, clientY: 40 });
@@ -176,7 +176,7 @@ describe('ToolCallView', () => {
expect(refreshCanvasMock).toHaveBeenCalledTimes(1);
});
it('keeps media clicks passive and exposes canvas resources to the right-click menu', () => {
it('focuses image clicks only and exposes canvas resources to the right-click menu', () => {
const onRightClickMenu = vi.fn();
const { container } = renderWithCanvasActions(
<ToolCallView
@@ -221,9 +221,11 @@ describe('ToolCallView', () => {
fireEvent.click(imageCards[0]!);
fireEvent.keyDown(imageCards[0]!, { key: 'Enter' });
fireEvent.keyDown(imageCards[0]!, { key: ' ', code: 'Space' });
fireEvent.click(imageCards[1]!);
fireEvent.click(video!);
fireEvent.click(audio!);
expect(focusResourceMock).not.toHaveBeenCalled();
expect(focusResourceMock).toHaveBeenCalledTimes(1);
expect(focusResourceMock).toHaveBeenCalledWith('resource-image');
expect(
screen.queryByRole('button', {
name: '在画布中定位Agent生成图片-1',
@@ -11,6 +11,57 @@ import { getExternalGenerationJobStatus } from '@/src/services/external-generati
import type { RightClickMenuHandler } from './common.ts';
type ToolCallDisplayStatus = 'pending' | 'completed' | 'failed' | 'cancelled';
function getInitialDisplayStatus(
status: EditorAgentToolCall['status'],
): ToolCallDisplayStatus {
switch (status) {
case 'completed':
case 'failed':
case 'cancelled':
return status;
case 'not_completed':
return 'pending';
}
}
function getStatusPresentation(
displayStatus: ToolCallDisplayStatus,
hasJobId: boolean,
) {
switch (displayStatus) {
case 'completed':
return {
label: '已完成',
icon: <Check className="h-3.5 w-3.5" aria-hidden="true" />,
};
case 'failed':
return {
label: '失败',
icon: <X className="h-3.5 w-3.5" aria-hidden="true" />,
};
case 'cancelled':
return {
label: '已取消',
icon: <X className="h-3.5 w-3.5" aria-hidden="true" />,
};
case 'pending':
if (hasJobId) {
return {
label: '执行中',
icon: (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
),
};
}
return {
label: '待确认',
icon: <ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />,
};
}
}
function ToolCallView({
toolCall,
onJobCompleted,
@@ -20,23 +71,17 @@ function ToolCallView({
onJobCompleted?: () => void;
onRightClickMenu?: RightClickMenuHandler;
}) {
const { refreshCanvas } = useImageCanvasActions();
const { focusResource, refreshCanvas } = useImageCanvasActions();
const videos = toolCall.videos ?? [];
const audios = toolCall.audios ?? [];
const jobId = toolCall.externalJobId?.trim() || null;
const initialDisplayStatus =
toolCall.status === 'completed'
? 'completed'
: toolCall.status === 'failed'
? 'failed'
: toolCall.status === 'cancelled'
? 'cancelled'
: 'pending';
const initialDisplayStatus = getInitialDisplayStatus(toolCall.status);
const initialDisplayError = toolCall.error ?? null;
const shouldPoll = toolCall.status === 'not_completed' && Boolean(jobId);
const [displayStatus, setDisplayStatus] = useState(initialDisplayStatus);
const [displayError, setDisplayError] =
useState<string | null>(initialDisplayError);
const [displayError, setDisplayError] = useState<string | null>(
initialDisplayError,
);
const terminalNotifiedRef = useRef(false);
const onJobCompletedRef = useRef(onJobCompleted);
const refreshCanvasRef = useRef(refreshCanvas);
@@ -83,33 +128,18 @@ function ToolCallView({
// 轮询只允许新的 job/status source 重置;其余值通过当前 source 对应的闭包读取。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialDisplayError, initialDisplayStatus, jobId, shouldPoll]);
const isCancelled = displayStatus === 'cancelled';
const isCompleted = displayStatus === 'completed';
const isFailed = displayStatus === 'failed';
const isExecuting = displayStatus === 'pending' && Boolean(jobId);
const statusLabel = isCompleted
? '已完成'
: isFailed
? '失败'
: isCancelled
? '已取消'
: isExecuting
? '执行中'
: '待确认';
const statusPresentation = getStatusPresentation(
displayStatus,
Boolean(jobId),
);
return (
<div className="rounded-lg border border-slate-200 bg-white/80 p-2 text-xs text-slate-600">
<div className="flex items-center gap-2">
{isExecuting ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
) : isCompleted ? (
<Check className="h-3.5 w-3.5" aria-hidden="true" />
) : isCancelled || isFailed ? (
<X className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ImageIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{statusPresentation.icon}
<span>{editorAgentToolLabel(toolCall.toolName)}</span>
<span className="ml-auto text-slate-400">{statusLabel}</span>
<span className="ml-auto text-slate-400">
{statusPresentation.label}
</span>
</div>
{displayError ? (
<div className="mt-1 text-red-600">{displayError}</div>
@@ -122,6 +152,9 @@ function ToolCallView({
<div
key={`${toolCall.toolName}-${image.resourceId ?? index}`}
className="overflow-hidden rounded-xl border border-slate-200 bg-slate-100"
onClick={
resourceId ? () => focusResource(resourceId) : undefined
}
onContextMenu={
onRightClickMenu
? (event) =>