feat: split the prompt to 1.persist message 2.planning (which is abortable) to make sure user message is always persisted.
This commit is contained in:
@@ -32,5 +32,5 @@
|
||||
- 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。
|
||||
- 会话软删只打表标记,OSS 对象保留,便于恢复与审计。
|
||||
- 规划或工具生成失败也必须写入消息文档:规划失败保存 `ERROR ` system 消息,工具失败保存失败状态、模型和错误信息,便于用户回看失败原因和后续排障。
|
||||
- 用户主动中断普通规划时,浏览器 abort 当前 HTTP 请求,Axum / Hyper drop handler 并释放会话锁。该情况不记为规划失败、不追加 `ERROR`;因用户消息已在 LLM 前写入 OSS,其作为普通历史保留,前端中断后重读会话完成对齐。
|
||||
- 普通消息先由 `/messages` 写入 OSS 并返回 ACK,再由 `/messages/plan` 执行规划。用户主动中断时只 abort plan 请求,Axum / Hyper drop plan handler 并释放会话锁;该情况不记为规划失败、不追加 `ERROR`。创建会话或持久化期间提前停止仍等待 ACK,随后跳过规划,因此停止成功时用户消息必然作为普通历史保留。
|
||||
- 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
## 2026-07-22 画布 Agent 普通规划通过 HTTP abort 中断
|
||||
|
||||
- 背景:画布 Agent 规划可在 `agent.prompt(...)` 及 LLM 重试中长时间等待,用户输入有误时需要立即释放会话锁并发送新 prompt。2026-07-17 的旧决策以“fetch 中断不能保证后端停止”为前提移除了停止入口,但当前 Axum 0.8.9 / Hyper 1.8.1 和 Pingora 代理链可通过真实断连回归验证 handler Future 被 drop。
|
||||
- 决策:普通消息等待期间将“发送”切换为“停止”。前端为每轮请求创建 `AbortController`,主动停止时 abort `/messages` POST;Hyper 在断连 / HTTP/2 `RST_STREAM` 后 drop `editor_agent_message`,连带 drop `agent.prompt(...)` 与 LLM reqwest Future,RAII 释放 conversation lock。`AbortError` 不进入 POST transport retry、不展示失败、不删除已持久化的用户消息;停止后的静默会话 GET 作为尽力而为的 UI 对账直接 `await` 在 abort 分支内,成功时应用权威详情,失败时静默保留当前消息。`isAborting` 覆盖从 abort 开始到 GET 成功或失败结束的整个阶段,期间 `isWaiting` 继续阻止下一次发送,GET 结束后允许新 prompt。锁释放由 handler drop 与 RAII 保证,不把 GET 成功设为恢复前置条件;后续 POST 重新读取 OSS 权威会话并按 `clientMessageId` 幂等处理,因此对账失败不会破坏后端一致性。不新增 abort API、运行态 registry、消息状态或 SpacetimeDB 字段。
|
||||
- 决策:普通消息拆成不可由用户停止取消的 `/messages` 持久化 POST 和可取消的 `/messages/plan` 规划 POST。前端从创建会话前登记整轮停止状态,但创建与持久化始终正常收口;用户提前停止时等待 OSS ACK 后跳过规划,规划阶段停止才 abort HTTP。持久化 ACK 返回权威用户消息并替换 optimistic message;持久化失败恢复输入并报错,规划失败不删除已经落 OSS 的用户消息。Hyper 断连后只 drop plan handler、`agent.prompt(...)` 与 LLM reqwest Future,RAII 释放 conversation lock;规划 abort 后不再额外 GET 对账,plan 请求结束即恢复发送。不新增运行态 registry、消息状态或 SpacetimeDB 字段。
|
||||
- 边界:这会 drop 整个当前 HTTP handler,不是已确认 external generation job 的取消;上游模型已接收请求后是否立即停止计算由 provider 决定。用户消息保留为普通历史,会继续进入后续 LLM 上下文。
|
||||
- 影响范围:画布 Agent 对话 hook、发送区交互、API client abort 识别、Axum / Pingora 断连回归和专题 / 架构文档。
|
||||
- 验证方式:前端定向测试锁定 signal、按钮、不重试和中断后新 prompt;api-server 真实 TCP 回归锁定 HTTP 请求 drop 后 handler 析构与 mutex 释放;Pingora smoke 锁定下游中断传播到 API 上游。追加类型检查、编码和 diff 门禁。
|
||||
|
||||
@@ -28,12 +28,12 @@
|
||||
|
||||
后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。
|
||||
|
||||
画布 Agent 已改为 `POST /api/editor/agent-conversations/{conversationId}/messages` 普通 JSON 请求,不属于本 SSE 传输层的落地范围;其客户端只通过 `requestJson` 读取 `EditorAgentMessageResponse`,不得为了恢复旧文档口径重新增加私有 SSE parser。
|
||||
画布 Agent 使用 `/messages` 持久化与 `/messages/plan` 规划两个普通 JSON POST,不属于本 SSE 传输层的落地范围;其客户端只通过 `requestJson` 读取持久化 ACK 和 `EditorAgentMessageResponse`,不得为了恢复旧文档口径重新增加私有 SSE parser。
|
||||
|
||||
## 验收
|
||||
|
||||
- `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。
|
||||
- `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。
|
||||
- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD 和 `/messages` 普通 JSON 路由;画布 Agent 不纳入 SSE parser 验收。
|
||||
- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD、`/messages` 持久化和 `/messages/plan` 规划 JSON 路由;画布 Agent 不纳入 SSE parser 验收。
|
||||
- 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。
|
||||
- `npm run typecheck` 不产生新的类型错误。
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
- `POST /api/editor/projects/{projectId}/agent-conversations`:在当前工程下创建画布 Agent 会话;可选传入标题,默认标题为“新对话”。
|
||||
- `GET /api/editor/agent-conversations/{conversationId}`:读取指定画布 Agent 会话详情,返回会话摘要和 OSS 消息正文中的消息列表。
|
||||
- `DELETE /api/editor/agent-conversations/{conversationId}`:软删除指定画布 Agent 会话,并返回删除后的会话摘要。
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。LLM / 规划失败写入 `role=system`、正文以 `ERROR ` 开头的 OSS 消息并放入 `deltaMessages`,不再重复设置 `errorMessage`;前端隐藏前缀后显示红色错误气泡。工具失败继续保存工具状态和错误信息。
|
||||
- 画布 Agent 普通消息采用两阶段 JSON:`POST /api/editor/agent-conversations/{conversationId}/messages` 接收 `clientMessageId`、`text` 和附件,写入 OSS 后返回权威会话摘要与 `userMessage`;`POST .../messages/plan` 只接收同一 `clientMessageId`,从 OSS 读取消息并返回 `deltaMessages`。前端不把用户 AbortSignal 传给持久化阶段,只允许中止规划阶段。LLM / 规划失败继续写入 `role=system`、正文以 `ERROR ` 开头的 OSS 消息并放入规划响应,工具失败继续保存工具状态和错误信息。
|
||||
- `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。
|
||||
- `POST /api/editor/assets/folders`:新建素材文件夹。
|
||||
- `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。
|
||||
@@ -134,7 +134,7 @@
|
||||
- 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开,任务侧栏被收起;素材 / 图层侧栏保持当前状态并可继续切换。再次点击或点击面板关闭按钮后收起 Agent;打开任务侧栏时 Agent 面板同步关闭。
|
||||
- Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。
|
||||
- Agent 输入支持文本消息、附件消息和纯附件消息;附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。
|
||||
- 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。
|
||||
- 发送消息后,面板先展示本地用户消息并等待持久化 ACK,用 ACK 中的权威用户消息替换 optimistic message,再发起可中止规划并应用 `deltaMessages`。创建或持久化期间点击停止只记录停止意图并等待消息落 OSS,ACK 后跳过规划;规划期间停止才终止 transport,不影响已确认入队的外部生成任务。
|
||||
- Agent 工具任务完成并懒回填后,消息内缩略图只作纯预览,不显示名称也不点击聚焦图层;前端同时重新读取工程快照和素材库。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。
|
||||
- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。
|
||||
|
||||
@@ -72,12 +72,12 @@ npm run check:server-rs-ddd
|
||||
|
||||
### 图片画布 Agent 对话
|
||||
|
||||
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
|
||||
- 用户主动中断普通规划时,前端通过 `AbortController` 取消当前 `/messages` POST,该 `AbortError` 不进入 transport retry。Hyper 在断连或 HTTP/2 `RST_STREAM` 后 drop `editor_agent_message` Future,连带 drop `agent.prompt(...)` 和 LLM reqwest Future,RAII 释放 conversation lock。
|
||||
- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删。普通消息固定拆成两个 JSON POST:`POST /api/editor/agent-conversations/{conversationId}/messages` 校验并持久化用户消息,成功返回 `EditorAgentMessagePersistResponse`;`POST .../messages/plan` 只接收 `clientMessageId`,从 OSS 权威文档读取对应用户消息并返回 `EditorAgentMessageResponse`。画布 Agent 不提供 `/messages/stream` SSE 路由。两个请求都显式启用 1 次瞬时 transport 重试并复用同一个业务 `clientMessageId`;持久化请求在会话锁内按该键幂等,同内容返回已有用户消息,异内容返回 `409`,规划请求找不到已持久化消息或目标消息已被后续用户回合取代时返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符。
|
||||
- 用户主动中断普通规划时,前端通过 `AbortController` 取消当前 `/messages/plan` POST,该 `AbortError` 不进入 transport retry。Hyper 在断连或 HTTP/2 `RST_STREAM` 后 drop plan handler Future,连带 drop `agent.prompt(...)` 和 LLM reqwest Future,RAII 释放 conversation lock。
|
||||
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。
|
||||
- `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。
|
||||
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
|
||||
- 主动 HTTP abort 不是规划失败,不追加 `ERROR` 消息。用户消息在进入 LLM 前已写入 OSS,中断后继续作为普通历史保留并进入后续 LLM 上下文。前端在恢复发送前尽力重新读取一次会话:GET 成功时应用 OSS 权威历史,失败时保留当前 UI 消息并在该 GET 结束后恢复发送,不把对账失败当作发送失败。旧 handler 的 drop 与 conversation lock 释放由 Hyper 断连传播和 RAII 保证,不以该 GET 成功作为确认条件;后续 POST 会重新读取 OSS 权威会话并按 `clientMessageId` 幂等处理,因此 GET 失败不会破坏后端一致性。
|
||||
- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。持久化 POST 只有在 OSS 用户消息写入和会话元数据 touch 完成后才返回 ACK;规划 POST 不再接受或补写文本、附件。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡。工具失败同样必须形成可回读记录,不能只返回瞬时错误。
|
||||
- 主动 HTTP abort 只作用于 `/messages/plan`,不是规划失败,不追加 `ERROR` 消息。创建会话和 `/messages` 持久化阶段不绑定用户 AbortSignal;用户提前停止时仍等待持久化 ACK,随后跳过规划。用户消息因此在进入可中止阶段前已写入 OSS,中断后继续作为普通历史并进入后续 LLM 上下文。规划中止后前端不再额外 GET 对账,plan 请求结束即恢复发送;plan handler 的 drop 与 conversation lock 释放由 Hyper 断连传播和 RAII 保证。
|
||||
- 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 `max_tokens`。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
|
||||
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
|
||||
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。
|
||||
- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。
|
||||
- 已落地:普通消息规划期间可主动“停止”。前端通过 `AbortController` 取消当前 `/messages` HTTP 请求;Axum / Hyper 收到连接关闭或 HTTP/2 `RST_STREAM` 后 drop `editor_agent_message` Future,连带 drop `agent.prompt(...)` 和 LLM reqwest Future,并由 RAII 释放 conversation lock。这不是已确认外部生成任务的取消能力。
|
||||
- 已落地:普通消息先通过 `/messages` 持久化,再通过 `/messages/plan` 规划;规划期间可主动“停止”。前端只取消 plan HTTP 请求,Axum / Hyper 收到连接关闭或 HTTP/2 `RST_STREAM` 后 drop plan handler、`agent.prompt(...)` 和 LLM reqwest Future,并由 RAII 释放 conversation lock。这不是已确认外部生成任务的取消能力。
|
||||
- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。
|
||||
|
||||
## 会话与持久化
|
||||
@@ -45,7 +45,7 @@
|
||||
- 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。
|
||||
- 会话标题:新会话默认「新对话」,首条含文本的用户消息发出后自动截取前 N 字作为标题;列表摘要、详情和消息回包均携带同一必填标题,前端只展示该标题,不以会话 ID 或本地推导兜底。标题写入失败会使该消息请求失败,不能静默继续。
|
||||
- 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。
|
||||
- 每次用户主动发送生成一个最长 128 字符的 `clientMessageId`;`editorAgentClient` 对网络错误和通用瞬时状态码显式启用 1 次 POST transport 重试,重试复用同一个已序列化 body、`clientMessageId` 和 `x-request-id`。该字段独立于数字 `message.id` 并随用户消息写入 OSS。旧消息缺失时按 `None` 兼容;早期 SSE 文档若把客户端键存成用户消息字符串 `id`,读取时将其迁入 `clientMessageId`,同时重建数字定位符。后端在会话锁内检查重复键:内容一致时返回已持久化的同一回合结果,尚无结果时复用原用户消息继续规划;文本或附件身份不同则返回 `409`,不得再次追加用户消息或调用 LLM。
|
||||
- 每次用户主动发送生成一个最长 128 字符的 `clientMessageId`,先通过 `/messages` 持久化用户消息并取得 ACK,再通过 `/messages/plan` 对同一 ID 发起规划。两个 POST 对瞬时 transport 错误最多重试 1 次并复用同一业务 ID;持久化请求同 ID 同内容返回已有用户消息,异内容返回 `409`,规划请求只读取 OSS 中已存在的文本与附件,不接受前端重复提交消息内容。该字段独立于数字 `message.id` 并随用户消息写入 OSS。
|
||||
|
||||
## 生成结果落画板(对现有占位规则的例外)
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。
|
||||
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
|
||||
- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。
|
||||
- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。整轮发送从首次创建会话前就必须注册为可停止;若用户在新会话创建完成前停止,允许创建请求正常收口,但创建完成后不得继续发送规划请求。用户主动停止后不显示发送失败、不重试 POST、不删除已写入 OSS 的用户消息。停止后的静默会话 GET 是尽力而为的 UI 对账:成功时应用权威详情,失败时静默保留当前消息,不把对账失败误报为发送失败,也不阻塞后续发送。`isAborting` 覆盖从 abort 开始到该 GET 成功或失败结束的整个阶段,期间 `isWaiting` 继续阻止下一次发送;GET 结束后一次性退出“停止中”并恢复发送。该阶段不显示“刷新中”或额外 loading。后续 POST 由后端重新读取 OSS 权威会话,并通过 `clientMessageId` 保证回合幂等,不依赖前端当前消息列表,因此对账 GET 失败只会让 UI 暂时未刷新,不会破坏后端会话一致性。
|
||||
- 普通消息回包必须绑定发送时的会话。整轮发送从首次创建会话前就注册为可停止,但停止只取消规划:在创建会话或持久化 pending 时点击停止,界面进入“停止中”,创建和持久化继续执行,ACK 返回后用权威 `userMessage` 替换 optimistic message 并跳过 `/messages/plan`;持久化失败必须显示错误并恢复输入,不能静默当作停止成功。规划 pending 时停止才 abort `/messages/plan`。因为该阶段前用户消息已由 ACK 确认,abort 后不额外 GET 对账;plan 请求结束时直接退出“停止中”并恢复发送。
|
||||
- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
|
||||
|
||||
## 附件
|
||||
@@ -97,12 +97,12 @@
|
||||
## LLM 与计费
|
||||
|
||||
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。
|
||||
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory。`/messages/plan` 尚未结束只表示 provider request future 仍在等待,用户消息本身已由前序持久化 ACK 确认落入 OSS。
|
||||
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
|
||||
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
|
||||
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。
|
||||
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。plan POST 发出后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若请求仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;消息规划生命周期从 plan handler 入口开始计入 18 分钟总 deadline,不包含会话创建和用户消息持久化耗时。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费。
|
||||
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
|
||||
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
|
||||
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
|
||||
@@ -143,7 +143,8 @@
|
||||
- `api-server`:
|
||||
- `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建);
|
||||
- `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删);
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages`(JSON);
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages`(持久化用户消息 JSON);
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages/plan`(可中止规划 JSON);
|
||||
- Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。
|
||||
- `shared-contracts` + `packages/shared`:`editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation`、`deltaMessages` 和可选 `errorMessage`。
|
||||
|
||||
|
||||
@@ -149,6 +149,15 @@ export interface EditorAgentMessageRequest {
|
||||
attachments?: EditorAgentAttachmentRef[];
|
||||
}
|
||||
|
||||
export interface EditorAgentMessagePersistResponse {
|
||||
conversation: EditorAgentConversationSummary;
|
||||
userMessage: EditorAgentMessage;
|
||||
}
|
||||
|
||||
export interface EditorAgentMessagePlanRequest {
|
||||
clientMessageId: string;
|
||||
}
|
||||
|
||||
export interface EditorAgentMessageResponse {
|
||||
conversation: EditorAgentConversationSummary;
|
||||
deltaMessages: EditorAgentMessage[];
|
||||
|
||||
@@ -24,8 +24,9 @@ use shared_contracts::editor_agent::{
|
||||
CreateEditorAgentConversationRequest, EDITOR_AGENT_ERROR_MESSAGE_PREFIX,
|
||||
EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument,
|
||||
EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentMessage,
|
||||
EditorAgentMessageRequest, EditorAgentMessageResponse, EditorAgentMessageRole,
|
||||
EditorAgentToolCall, EditorAgentToolCallStatus,
|
||||
EditorAgentMessagePersistResponse, EditorAgentMessagePlanRequest, EditorAgentMessageRequest,
|
||||
EditorAgentMessageResponse, EditorAgentMessageRole, EditorAgentToolCall,
|
||||
EditorAgentToolCallStatus,
|
||||
};
|
||||
use spacetime_client::{
|
||||
EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput,
|
||||
@@ -86,14 +87,13 @@ const EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE: &str = "规划总时长已达到 18
|
||||
const EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE: &str = "美术 Agent 服务暂不可用,请稍后重试";
|
||||
const EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE: &str = "美术 Agent 生成定价暂不可用,请稍后重试";
|
||||
|
||||
pub async fn editor_agent_message(
|
||||
pub async fn persist_editor_agent_message(
|
||||
State(state): State<AppState>,
|
||||
Path(conversation_id): Path<String>,
|
||||
Extension(_request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAgentMessageRequest>,
|
||||
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
|
||||
let message_started_at = Instant::now();
|
||||
) -> Result<Json<EditorAgentMessagePersistResponse>, AppError> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let client_message_id = validate_editor_agent_message_request(&payload)?;
|
||||
@@ -127,79 +127,118 @@ pub async fn editor_agent_message(
|
||||
attachments.as_slice(),
|
||||
)?;
|
||||
|
||||
let (user_message, history_end, conversation_summary) =
|
||||
if let Some(user_index) = existing_user_index {
|
||||
let delta_messages = document.messages[user_index + 1..]
|
||||
.iter()
|
||||
.take_while(|message| message.role != EditorAgentMessageRole::User)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if !delta_messages.is_empty() {
|
||||
return Ok(Json(EditorAgentMessageResponse {
|
||||
conversation: conversation_summary_from_record(conversation),
|
||||
delta_messages,
|
||||
error_message: None,
|
||||
}));
|
||||
}
|
||||
if let Some(user_index) = existing_user_index {
|
||||
let user_message = document.messages[user_index].clone();
|
||||
let is_first_user_message = document.messages[..user_index]
|
||||
.iter()
|
||||
.all(|message| message.role != EditorAgentMessageRole::User);
|
||||
// A previous attempt may have written OSS and then failed while touching metadata.
|
||||
// Replaying the same clientMessageId repairs that second half before returning an ACK.
|
||||
let updated_conversation = state
|
||||
.spacetime_client()
|
||||
.touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput {
|
||||
conversation_id: conversation.conversation_id.clone(),
|
||||
owner_user_id: conversation.owner_user_id.clone(),
|
||||
title: is_first_user_message
|
||||
.then(|| derive_conversation_title(user_message.text.as_str())),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
return Ok(Json(EditorAgentMessagePersistResponse {
|
||||
conversation: conversation_summary_from_record(updated_conversation),
|
||||
user_message,
|
||||
}));
|
||||
}
|
||||
|
||||
(
|
||||
document.messages[user_index].clone(),
|
||||
user_index,
|
||||
conversation_summary_from_record(conversation.clone()),
|
||||
)
|
||||
} else {
|
||||
// Determine initialization before attachment bookkeeping adds a system message.
|
||||
let was_empty = document.messages.is_empty();
|
||||
let now = now_rfc3339();
|
||||
if !attachments.is_empty() {
|
||||
let mut attachment_info = String::new();
|
||||
attachment_info.push_str("user has just uploaded attachments of the order: ");
|
||||
for attachment in &attachments {
|
||||
attachment_info.push_str(&format!("{} ,", attachment.clone().into_image_id()));
|
||||
}
|
||||
document.messages.push(EditorAgentMessage {
|
||||
id: document.messages.len(),
|
||||
client_message_id: None,
|
||||
role: EditorAgentMessageRole::System,
|
||||
text: attachment_info,
|
||||
attachments: Vec::new(),
|
||||
tool_call: None,
|
||||
created_at: now.clone(),
|
||||
});
|
||||
}
|
||||
// Determine initialization before attachment bookkeeping adds a system message.
|
||||
let was_empty = document.messages.is_empty();
|
||||
let now = now_rfc3339();
|
||||
if !attachments.is_empty() {
|
||||
let mut attachment_info = String::new();
|
||||
attachment_info.push_str("user has just uploaded attachments of the order: ");
|
||||
for attachment in &attachments {
|
||||
attachment_info.push_str(&format!("{} ,", attachment.clone().into_image_id()));
|
||||
}
|
||||
document.messages.push(EditorAgentMessage {
|
||||
id: document.messages.len(),
|
||||
client_message_id: None,
|
||||
role: EditorAgentMessageRole::System,
|
||||
text: attachment_info,
|
||||
attachments: Vec::new(),
|
||||
tool_call: None,
|
||||
created_at: now.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let history_end = document.messages.len();
|
||||
let user_message = EditorAgentMessage {
|
||||
id: document.messages.len(),
|
||||
client_message_id: Some(client_message_id),
|
||||
role: EditorAgentMessageRole::User,
|
||||
text: normalized_text,
|
||||
attachments,
|
||||
tool_call: None,
|
||||
created_at: now,
|
||||
};
|
||||
document.messages.push(user_message.clone());
|
||||
write_messages_document(&state, &conversation, &document).await?;
|
||||
let user_message = EditorAgentMessage {
|
||||
id: document.messages.len(),
|
||||
client_message_id: Some(client_message_id),
|
||||
role: EditorAgentMessageRole::User,
|
||||
text: normalized_text,
|
||||
attachments,
|
||||
tool_call: None,
|
||||
created_at: now,
|
||||
};
|
||||
document.messages.push(user_message.clone());
|
||||
write_messages_document(&state, &conversation, &document).await?;
|
||||
|
||||
// Persist and return the authoritative summary for every turn. Initialization sets the
|
||||
// title from the first user prompt; a metadata write failure must fail the request.
|
||||
let updated_conversation = state
|
||||
.spacetime_client()
|
||||
.touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput {
|
||||
conversation_id: conversation.conversation_id.clone(),
|
||||
owner_user_id: conversation.owner_user_id.clone(),
|
||||
title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
// The ACK is returned only after both the OSS document and conversation metadata are durable.
|
||||
let updated_conversation = state
|
||||
.spacetime_client()
|
||||
.touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput {
|
||||
conversation_id: conversation.conversation_id.clone(),
|
||||
owner_user_id: conversation.owner_user_id.clone(),
|
||||
title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())),
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
(
|
||||
user_message,
|
||||
history_end,
|
||||
conversation_summary_from_record(updated_conversation),
|
||||
)
|
||||
};
|
||||
Ok(Json(EditorAgentMessagePersistResponse {
|
||||
conversation: conversation_summary_from_record(updated_conversation),
|
||||
user_message,
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn plan_editor_agent_message(
|
||||
State(state): State<AppState>,
|
||||
Path(conversation_id): Path<String>,
|
||||
Extension(_request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
Json(payload): Json<EditorAgentMessagePlanRequest>,
|
||||
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
|
||||
let message_started_at = Instant::now();
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
|
||||
let client_message_id = validate_editor_agent_client_message_id(&payload.client_message_id)?;
|
||||
let conversation = state
|
||||
.spacetime_client()
|
||||
.get_editor_agent_conversation(conversation_id, owner_user_id)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
AppError::from_status(axum::http::StatusCode::NOT_FOUND)
|
||||
.with_details(json!({ "message": format!("conversation not found: {e}") }))
|
||||
})?;
|
||||
|
||||
let conversation_lock = crate::editor_agent::utils::editor_agent_conversation_lock(
|
||||
conversation.conversation_id.as_str(),
|
||||
);
|
||||
let _conversation_lock_guard = conversation_lock.lock_owned().await;
|
||||
let mut document = read_messages_document(&state, &conversation).await?;
|
||||
let (user_index, delta_messages) =
|
||||
find_editor_agent_user_message_for_plan(&document, client_message_id.as_str())?;
|
||||
let conversation_summary = conversation_summary_from_record(conversation.clone());
|
||||
if !delta_messages.is_empty() {
|
||||
return Ok(Json(EditorAgentMessageResponse {
|
||||
conversation: conversation_summary,
|
||||
delta_messages,
|
||||
error_message: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let user_message = document.messages[user_index].clone();
|
||||
let history_end = user_index;
|
||||
|
||||
// The current user message is passed separately to prompt(), so memory stops before it.
|
||||
let previous_messages: Vec<LlmMessage> = document.messages[..history_end]
|
||||
@@ -381,13 +420,7 @@ async fn persist_editor_agent_planning_error(
|
||||
fn validate_editor_agent_message_request(
|
||||
payload: &EditorAgentMessageRequest,
|
||||
) -> Result<String, AppError> {
|
||||
let client_message_id = normalize_required_string(payload.client_message_id.as_str())
|
||||
.ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?;
|
||||
if client_message_id.chars().count() > EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS {
|
||||
return Err(editor_agent_bad_request(format!(
|
||||
"clientMessageId must not exceed {EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
let client_message_id = validate_editor_agent_client_message_id(&payload.client_message_id)?;
|
||||
let attachment_reference_ids = payload
|
||||
.attachments
|
||||
.iter()
|
||||
@@ -398,6 +431,25 @@ fn validate_editor_agent_message_request(
|
||||
Ok(client_message_id)
|
||||
}
|
||||
|
||||
fn validate_editor_agent_client_message_id(client_message_id: &str) -> Result<String, AppError> {
|
||||
let client_message_id = normalize_required_string(client_message_id)
|
||||
.ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?;
|
||||
if client_message_id.chars().count() > EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS {
|
||||
return Err(editor_agent_bad_request(format!(
|
||||
"clientMessageId must not exceed {EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS} characters"
|
||||
)));
|
||||
}
|
||||
Ok(client_message_id)
|
||||
}
|
||||
|
||||
fn editor_agent_conflict(reason: &str, message: &str) -> AppError {
|
||||
AppError::from_status(axum::http::StatusCode::CONFLICT).with_details(json!({
|
||||
"provider": "editor-agent",
|
||||
"reason": reason,
|
||||
"message": message,
|
||||
}))
|
||||
}
|
||||
|
||||
fn find_idempotent_editor_agent_user_message(
|
||||
document: &EditorAgentConversationMessagesDocument,
|
||||
client_message_id: &str,
|
||||
@@ -439,6 +491,33 @@ fn editor_agent_attachment_requests_match(
|
||||
})
|
||||
}
|
||||
|
||||
fn find_editor_agent_user_message_for_plan(
|
||||
document: &EditorAgentConversationMessagesDocument,
|
||||
client_message_id: &str,
|
||||
) -> Result<(usize, Vec<EditorAgentMessage>), AppError> {
|
||||
let Some(user_index) = document.messages.iter().position(|message| {
|
||||
message.role == EditorAgentMessageRole::User
|
||||
&& message.client_message_id.as_deref() == Some(client_message_id)
|
||||
}) else {
|
||||
return Err(editor_agent_conflict(
|
||||
"message_not_persisted",
|
||||
"clientMessageId does not reference a persisted user message",
|
||||
));
|
||||
};
|
||||
|
||||
if document.messages[user_index + 1..]
|
||||
.iter()
|
||||
.any(|message| message.role == EditorAgentMessageRole::User)
|
||||
{
|
||||
return Err(editor_agent_conflict(
|
||||
"message_superseded",
|
||||
"a newer user message already exists",
|
||||
));
|
||||
}
|
||||
|
||||
Ok((user_index, document.messages[user_index + 1..].to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -569,6 +648,63 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planning_requires_a_persisted_latest_user_message_and_reuses_delta() {
|
||||
let user_message = EditorAgentMessage {
|
||||
id: 0,
|
||||
client_message_id: Some("client-message-1".to_string()),
|
||||
role: EditorAgentMessageRole::User,
|
||||
text: "生成一张图".to_string(),
|
||||
attachments: Vec::new(),
|
||||
tool_call: None,
|
||||
created_at: "2026-07-16T00:00:00Z".to_string(),
|
||||
};
|
||||
let assistant_message = EditorAgentMessage {
|
||||
id: 1,
|
||||
client_message_id: None,
|
||||
role: EditorAgentMessageRole::Assistant,
|
||||
text: "我来规划".to_string(),
|
||||
attachments: Vec::new(),
|
||||
tool_call: None,
|
||||
created_at: "2026-07-16T00:00:01Z".to_string(),
|
||||
};
|
||||
let document = EditorAgentConversationMessagesDocument {
|
||||
version: 2,
|
||||
conversation_id: "conversation-1".to_string(),
|
||||
messages: vec![user_message.clone(), assistant_message.clone()],
|
||||
};
|
||||
|
||||
let (user_index, delta_messages) =
|
||||
find_editor_agent_user_message_for_plan(&document, "client-message-1")
|
||||
.expect("persisted latest user message should be plannable");
|
||||
assert_eq!(user_index, 0);
|
||||
assert_eq!(delta_messages, vec![assistant_message]);
|
||||
assert!(
|
||||
find_editor_agent_user_message_for_plan(&document, "missing-client-message").is_err()
|
||||
);
|
||||
|
||||
let superseded_document = EditorAgentConversationMessagesDocument {
|
||||
version: 2,
|
||||
conversation_id: "conversation-1".to_string(),
|
||||
messages: vec![
|
||||
user_message,
|
||||
EditorAgentMessage {
|
||||
id: 1,
|
||||
client_message_id: Some("client-message-2".to_string()),
|
||||
role: EditorAgentMessageRole::User,
|
||||
text: "改成像素风".to_string(),
|
||||
attachments: Vec::new(),
|
||||
tool_call: None,
|
||||
created_at: "2026-07-16T00:00:02Z".to_string(),
|
||||
},
|
||||
],
|
||||
};
|
||||
assert!(
|
||||
find_editor_agent_user_message_for_plan(&superseded_document, "client-message-1")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_system_error_message_with_wire_prefix() {
|
||||
let message = build_editor_agent_error_message(3, "planning failed");
|
||||
|
||||
@@ -8,7 +8,7 @@ use axum::{
|
||||
|
||||
use crate::{
|
||||
auth::require_bearer_auth,
|
||||
editor_agent::api::editor_agent_message,
|
||||
editor_agent::api::{persist_editor_agent_message, plan_editor_agent_message},
|
||||
editor_agent::{
|
||||
cancel_editor_agent_tool_call, confirm_editor_agent_tool_call,
|
||||
create_editor_agent_conversation, delete_editor_agent_conversation,
|
||||
@@ -104,13 +104,20 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
)
|
||||
.route(
|
||||
"/api/editor/agent-conversations/{conversation_id}/messages",
|
||||
post(editor_agent_message)
|
||||
post(persist_editor_agent_message)
|
||||
.layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES))
|
||||
.route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/agent-conversations/{conversation_id}/messages/plan",
|
||||
post(plan_editor_agent_message).route_layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.route(
|
||||
"/api/editor/agent-conversations/{conversation_id}/messages/{message_id}/confirm",
|
||||
post(confirm_editor_agent_tool_call).route_layer(middleware::from_fn_with_state(
|
||||
|
||||
@@ -515,6 +515,19 @@ pub struct EditorAgentMessageRequest {
|
||||
pub attachments: Vec<EditorAgentAttachmentRef>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAgentMessagePersistResponse {
|
||||
pub conversation: EditorAgentConversationSummary,
|
||||
pub user_message: EditorAgentMessage,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAgentMessagePlanRequest {
|
||||
pub client_message_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct EditorAgentMessageResponse {
|
||||
@@ -545,6 +558,12 @@ mod tests {
|
||||
.expect("clientMessageId should deserialize");
|
||||
assert_eq!(request.client_message_id, "client-message-1");
|
||||
|
||||
let plan_request = serde_json::from_value::<EditorAgentMessagePlanRequest>(json!({
|
||||
"clientMessageId": "client-message-1"
|
||||
}))
|
||||
.expect("plan request should deserialize");
|
||||
assert_eq!(plan_request.client_message_id, "client-message-1");
|
||||
|
||||
let message = EditorAgentMessage {
|
||||
id: 0,
|
||||
client_message_id: Some(request.client_message_id),
|
||||
|
||||
+47
-63
@@ -11,8 +11,8 @@ import {
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessagePersistResponse,
|
||||
EditorAgentMessageResponse,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
|
||||
@@ -92,7 +92,26 @@ function createClient(): EditorAgentConversationClient {
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
}),
|
||||
sendMessage: vi.fn().mockResolvedValue({
|
||||
persistMessage: vi.fn().mockImplementation(
|
||||
async (conversationId, payload) => ({
|
||||
conversation: {
|
||||
conversationId,
|
||||
projectId: 'project-1',
|
||||
title: payload.text || '新对话',
|
||||
updatedAt: '2026-07-03T00:00:10.500Z',
|
||||
},
|
||||
userMessage: {
|
||||
id: 1,
|
||||
clientMessageId: payload.clientMessageId,
|
||||
role: 'user' as const,
|
||||
text: payload.text,
|
||||
attachments: payload.attachments ?? [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:10.500Z',
|
||||
},
|
||||
}),
|
||||
),
|
||||
planMessage: vi.fn().mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
@@ -301,7 +320,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy();
|
||||
});
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({
|
||||
text: '参考附件做像素风',
|
||||
@@ -312,7 +331,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -395,7 +413,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
@@ -408,7 +426,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -416,7 +433,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
it('replaces send with interrupt while a message request is pending', async () => {
|
||||
const client = createClient();
|
||||
let resolveSend!: (response: EditorAgentMessageResponse) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementation(
|
||||
vi.mocked(client.planMessage).mockImplementation(
|
||||
() =>
|
||||
new Promise<EditorAgentMessageResponse>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
@@ -469,11 +486,11 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps post-abort refresh inside the stopping state', async () => {
|
||||
it('returns to sending immediately after an aborted plan request', async () => {
|
||||
const client = createClient();
|
||||
let rejectSend!: (error: unknown) => void;
|
||||
const capturedRequest: { signal: AbortSignal | null } = { signal: null };
|
||||
vi.mocked(client.sendMessage).mockImplementationOnce(
|
||||
vi.mocked(client.planMessage).mockImplementationOnce(
|
||||
(_conversationId, _payload, options) =>
|
||||
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
||||
rejectSend = reject;
|
||||
@@ -492,14 +509,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('已经看到画布内容')).toBeTruthy();
|
||||
});
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
vi.mocked(client.getConversation).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentConversationDetail>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
|
||||
target: { value: '请中断这一轮' },
|
||||
});
|
||||
@@ -519,25 +528,8 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(screen.getByRole('button', { name: '停止中' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '发送' })).toBeNull();
|
||||
expect(screen.queryByText('刷新中')).toBeNull();
|
||||
expect(screen.queryByText('加载中')).toBeNull();
|
||||
expect(screen.getByText('请中断这一轮')).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:20.000Z',
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(await screen.findByRole('button', { name: '发送' })).toBeTruthy();
|
||||
expect(screen.queryByText('刷新中')).toBeNull();
|
||||
expect(client.getConversation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('uploads pasted images as canvas attachments before sending', async () => {
|
||||
@@ -595,7 +587,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
@@ -609,7 +601,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
|
||||
@@ -666,7 +657,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
@@ -682,7 +673,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -750,7 +740,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
@@ -762,7 +752,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -880,7 +869,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
@@ -890,7 +879,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
expect.objectContaining({ referenceId: 'resource-pasted' }),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1022,7 +1010,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
const request = vi.mocked(client.sendMessage).mock.calls[0]?.[1];
|
||||
const request = vi.mocked(client.persistMessage).mock.calls[0]?.[1];
|
||||
expect(request?.attachments).toHaveLength(9);
|
||||
expect(request?.attachments).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -1083,7 +1071,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
@@ -1094,14 +1082,13 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('restores the draft and selected attachments when sending fails', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockRejectedValueOnce(
|
||||
vi.mocked(client.persistMessage).mockRejectedValueOnce(
|
||||
new Error('Network error'),
|
||||
);
|
||||
|
||||
@@ -1165,9 +1152,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
it('merges sent attachments with references added while a failed request is pending', async () => {
|
||||
const client = createClient();
|
||||
let rejectSend!: (reason?: unknown) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementationOnce(
|
||||
vi.mocked(client.persistMessage).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
||||
new Promise<EditorAgentMessagePersistResponse>((_resolve, reject) => {
|
||||
rejectSend = reject;
|
||||
}),
|
||||
);
|
||||
@@ -1265,12 +1252,11 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
attachments: [expect.objectContaining({ referenceId: 'resource-a' })],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
await waitFor(() =>
|
||||
@@ -1295,9 +1281,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
it('keeps the current snapshot when failed recovery has the same attachment identity', async () => {
|
||||
const client = createClient();
|
||||
let rejectSend!: (reason?: unknown) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementationOnce(
|
||||
vi.mocked(client.persistMessage).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
||||
new Promise<EditorAgentMessagePersistResponse>((_resolve, reject) => {
|
||||
rejectSend = reject;
|
||||
}),
|
||||
);
|
||||
@@ -1349,7 +1335,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
attachments: [
|
||||
@@ -1359,7 +1345,6 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1394,9 +1379,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(client.persistMessage).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
vi.mocked(client.sendMessage).mock.calls[1]?.[1].attachments,
|
||||
vi.mocked(client.persistMessage).mock.calls[1]?.[1].attachments,
|
||||
).toEqual([
|
||||
expect.objectContaining({
|
||||
referenceId: 'resource-updated',
|
||||
@@ -1409,9 +1394,9 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
it('keeps the latest nine attachments when failed attachment recovery would exceed the limit', async () => {
|
||||
const client = createClient();
|
||||
let rejectSend!: (reason?: unknown) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementationOnce(
|
||||
vi.mocked(client.persistMessage).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
||||
new Promise<EditorAgentMessagePersistResponse>((_resolve, reject) => {
|
||||
rejectSend = reject;
|
||||
}),
|
||||
);
|
||||
@@ -1460,13 +1445,12 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({ attachments: expect.any(Array) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(
|
||||
vi.mocked(client.sendMessage).mock.calls[0]?.[1].attachments,
|
||||
vi.mocked(client.persistMessage).mock.calls[0]?.[1].attachments,
|
||||
).toHaveLength(9);
|
||||
});
|
||||
|
||||
@@ -1502,7 +1486,7 @@ describe('EditorAgentConversationPanelView', () => {
|
||||
|
||||
it('preserves messages when the panel is collapsed and reopened', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockResolvedValue({
|
||||
vi.mocked(client.planMessage).mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
+138
-70
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessagePersistResponse,
|
||||
EditorAgentMessageResponse,
|
||||
} from '../../../../packages/shared/src/contracts/editorAgent.ts';
|
||||
import {
|
||||
@@ -68,7 +69,26 @@ function createClient(): EditorAgentConversationClient {
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
}),
|
||||
sendMessage: vi.fn().mockResolvedValue({
|
||||
persistMessage: vi.fn().mockImplementation(
|
||||
async (conversationId, payload) => ({
|
||||
conversation: {
|
||||
conversationId,
|
||||
projectId: 'project-1',
|
||||
title: payload.text || '新对话',
|
||||
updatedAt: '2026-07-03T00:00:00.500Z',
|
||||
},
|
||||
userMessage: {
|
||||
id: 0,
|
||||
clientMessageId: payload.clientMessageId,
|
||||
role: 'user' as const,
|
||||
text: payload.text,
|
||||
attachments: payload.attachments ?? [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:00.500Z',
|
||||
},
|
||||
}),
|
||||
),
|
||||
planMessage: vi.fn().mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
@@ -142,13 +162,19 @@ describe('useEditorAgentConversation', () => {
|
||||
await result.current.sendMessage('把这个角色改成像素风');
|
||||
});
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
clientMessageId: expect.stringMatching(/^editor-agent-/u),
|
||||
text: '把这个角色改成像素风',
|
||||
attachments: [],
|
||||
}),
|
||||
);
|
||||
expect(client.planMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
clientMessageId: expect.stringMatching(/^editor-agent-/u),
|
||||
}),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
@@ -293,7 +319,7 @@ describe('useEditorAgentConversation', () => {
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
});
|
||||
vi.mocked(client.sendMessage).mockResolvedValue({
|
||||
vi.mocked(client.planMessage).mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
@@ -334,7 +360,7 @@ describe('useEditorAgentConversation', () => {
|
||||
const client = createClient();
|
||||
const onCanvasRefreshRequested = vi.fn();
|
||||
let resolveSend!: (response: EditorAgentMessageResponse) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementation(
|
||||
vi.mocked(client.planMessage).mockImplementation(
|
||||
() =>
|
||||
new Promise<EditorAgentMessageResponse>((resolve) => {
|
||||
resolveSend = resolve;
|
||||
@@ -443,9 +469,13 @@ describe('useEditorAgentConversation', () => {
|
||||
});
|
||||
|
||||
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({ text: '新建后发送' }),
|
||||
);
|
||||
expect(client.planMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({ clientMessageId: expect.any(String) }),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
@@ -494,14 +524,23 @@ describe('useEditorAgentConversation', () => {
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-2',
|
||||
expect.objectContaining({ text: '首次创建时停止' }),
|
||||
);
|
||||
expect(client.planMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.activeConversationId).toBe('conversation-2');
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
clientMessageId: expect.stringMatching(/^editor-agent-/u),
|
||||
text: '首次创建时停止',
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
expect(result.current.isAborting).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps a stopped first send silent when conversation creation fails', async () => {
|
||||
it('reports a stopped first send when conversation creation fails before persistence', async () => {
|
||||
const client = createClient();
|
||||
let rejectCreate!: (error: Error) => void;
|
||||
vi.mocked(client.listConversations).mockResolvedValueOnce([]);
|
||||
@@ -533,11 +572,78 @@ describe('useEditorAgentConversation', () => {
|
||||
|
||||
await act(async () => {
|
||||
rejectCreate(new Error('创建会话失败'));
|
||||
await expect(sendPromise).rejects.toThrow('创建会话失败');
|
||||
});
|
||||
|
||||
expect(client.persistMessage).not.toHaveBeenCalled();
|
||||
expect(client.planMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.errorMessage).toBe('创建会话失败');
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
expect(result.current.isAborting).toBe(false);
|
||||
});
|
||||
|
||||
it('waits for message persistence before completing an early stop', async () => {
|
||||
const client = createClient();
|
||||
let resolvePersist!: (response: EditorAgentMessagePersistResponse) => void;
|
||||
vi.mocked(client.persistMessage).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentMessagePersistResponse>((resolve) => {
|
||||
resolvePersist = resolve;
|
||||
}),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.activeConversationId).toBe('conversation-1');
|
||||
});
|
||||
|
||||
let sendPromise!: Promise<void>;
|
||||
act(() => {
|
||||
sendPromise = result.current.sendMessage('先保存,再停止');
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(client.persistMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const persistPayload = vi.mocked(client.persistMessage).mock.calls[0]?.[1];
|
||||
expect(persistPayload).toBeDefined();
|
||||
const persistedClientMessageId = persistPayload!.clientMessageId;
|
||||
|
||||
act(() => {
|
||||
result.current.stopCurrentTurn();
|
||||
});
|
||||
expect(result.current.isAborting).toBe(true);
|
||||
expect(client.planMessage).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
resolvePersist({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '先保存,再停止',
|
||||
updatedAt: '2026-07-03T00:00:20.000Z',
|
||||
},
|
||||
userMessage: {
|
||||
id: 1,
|
||||
clientMessageId: persistedClientMessageId,
|
||||
role: 'user',
|
||||
text: '先保存,再停止',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:20.000Z',
|
||||
},
|
||||
});
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.errorMessage).toBeNull();
|
||||
expect(client.planMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
clientMessageId: persistedClientMessageId,
|
||||
text: '先保存,再停止',
|
||||
}),
|
||||
]);
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
expect(result.current.isAborting).toBe(false);
|
||||
});
|
||||
@@ -614,7 +720,7 @@ describe('useEditorAgentConversation', () => {
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(client.sendMessage).not.toHaveBeenCalled();
|
||||
expect(client.planMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.activeConversationId).toBe('conversation-project-2');
|
||||
expect(result.current.messages.map((message) => message.text)).toEqual([
|
||||
'项目二消息',
|
||||
@@ -647,7 +753,7 @@ describe('useEditorAgentConversation', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
expect(client.sendMessage).toHaveBeenCalledWith(
|
||||
expect(client.persistMessage).toHaveBeenCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
text: '',
|
||||
@@ -658,13 +764,12 @@ describe('useEditorAgentConversation', () => {
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies persisted backend planning errors as system messages', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockResolvedValue({
|
||||
vi.mocked(client.planMessage).mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
@@ -710,7 +815,7 @@ describe('useEditorAgentConversation', () => {
|
||||
|
||||
it('confirms a pending tool call, replaces its message and requests a canvas refresh', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockResolvedValue({
|
||||
vi.mocked(client.planMessage).mockResolvedValue({
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
@@ -966,9 +1071,9 @@ describe('useEditorAgentConversation', () => {
|
||||
expect(onCanvasRefreshRequested).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rethrows fetch errors and rolls back the optimistic message', async () => {
|
||||
it('keeps the persisted user message when planning transport fails', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockRejectedValue(new Error('Network error'));
|
||||
vi.mocked(client.planMessage).mockRejectedValue(new Error('Network error'));
|
||||
const { result } = renderHook(() =>
|
||||
useEditorAgentConversation({ projectId: 'project-1', client }),
|
||||
);
|
||||
@@ -980,19 +1085,19 @@ describe('useEditorAgentConversation', () => {
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.sendMessage('test')).rejects.toThrow(
|
||||
'Network error',
|
||||
);
|
||||
await expect(result.current.sendMessage('test')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
expect(result.current.errorMessage).toBe('Network error');
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ text: 'test', role: 'user' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('replaces the extended patience notice with the actual request failure', async () => {
|
||||
const client = createClient();
|
||||
let rejectSend!: (error: Error) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementation(
|
||||
vi.mocked(client.planMessage).mockImplementation(
|
||||
() =>
|
||||
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
||||
rejectSend = reject;
|
||||
@@ -1029,12 +1134,14 @@ describe('useEditorAgentConversation', () => {
|
||||
expect(result.current.isPatienceNoticeVisible).toBe(false);
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
expect(result.current.errorMessage).toBe('LLM 连接已断开');
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
expect(result.current.messages).toEqual([
|
||||
expect.objectContaining({ text: '请继续', role: 'user' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('cleans the patience timer when the hook unmounts', async () => {
|
||||
const client = createClient();
|
||||
vi.mocked(client.sendMessage).mockImplementation(
|
||||
vi.mocked(client.planMessage).mockImplementation(
|
||||
() => new Promise<EditorAgentMessageResponse>(() => undefined),
|
||||
);
|
||||
const { result, unmount } = renderHook(() =>
|
||||
@@ -1066,7 +1173,7 @@ describe('useEditorAgentConversation', () => {
|
||||
const capturedRequest: { signal: AbortSignal | null } = { signal: null };
|
||||
let capturedClientMessageId = '';
|
||||
let rejectSend!: (error: unknown) => void;
|
||||
vi.mocked(client.sendMessage).mockImplementationOnce(
|
||||
vi.mocked(client.planMessage).mockImplementationOnce(
|
||||
(_conversationId, payload, options) =>
|
||||
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
|
||||
capturedClientMessageId = payload.clientMessageId;
|
||||
@@ -1094,7 +1201,7 @@ describe('useEditorAgentConversation', () => {
|
||||
});
|
||||
|
||||
expect(result.current.isWaiting).toBe(true);
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
expect(client.planMessage).toHaveBeenCalledTimes(1);
|
||||
expect(capturedRequest.signal?.aborted).toBe(false);
|
||||
expect(typeof result.current.stopCurrentTurn).toBe('function');
|
||||
|
||||
@@ -1104,24 +1211,16 @@ describe('useEditorAgentConversation', () => {
|
||||
expect(capturedRequest.signal?.aborted).toBe(true);
|
||||
expect(result.current.isAborting).toBe(true);
|
||||
|
||||
let resolveRefresh!: (detail: EditorAgentConversationDetail) => void;
|
||||
vi.mocked(client.getConversation).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<EditorAgentConversationDetail>((resolve) => {
|
||||
resolveRefresh = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
rejectSend(
|
||||
capturedRequest.signal?.reason ??
|
||||
new DOMException('aborted', 'AbortError'),
|
||||
);
|
||||
await Promise.resolve();
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
expect(result.current.isWaiting).toBe(true);
|
||||
expect(result.current.isAborting).toBe(true);
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
expect(result.current.isAborting).toBe(false);
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
expect(result.current.errorMessage).toBeNull();
|
||||
expect(result.current.messages).toEqual([
|
||||
@@ -1132,45 +1231,14 @@ describe('useEditorAgentConversation', () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('刷新完成前不应发送');
|
||||
});
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
resolveRefresh({
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '角色参考',
|
||||
messages: [
|
||||
{
|
||||
id: 1,
|
||||
clientMessageId: capturedClientMessageId,
|
||||
role: 'user',
|
||||
text: '请继续',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:20.000Z',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
updatedAt: '2026-07-03T00:00:20.000Z',
|
||||
});
|
||||
await sendPromise;
|
||||
});
|
||||
expect(result.current.isLoadingMessages).toBe(false);
|
||||
expect(result.current.isWaiting).toBe(false);
|
||||
expect(result.current.isAborting).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendMessage('新的请求');
|
||||
});
|
||||
expect(client.sendMessage).toHaveBeenCalledTimes(2);
|
||||
expect(client.sendMessage).toHaveBeenLastCalledWith(
|
||||
expect(client.planMessage).toHaveBeenCalledTimes(2);
|
||||
expect(client.planMessage).toHaveBeenLastCalledWith(
|
||||
'conversation-1',
|
||||
expect.objectContaining({
|
||||
clientMessageId: expect.not.stringMatching(capturedClientMessageId),
|
||||
text: '新的请求',
|
||||
}),
|
||||
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
||||
);
|
||||
|
||||
@@ -6,10 +6,13 @@ import type {
|
||||
EditorAgentConversationDetail,
|
||||
EditorAgentConversationSummary,
|
||||
EditorAgentMessage,
|
||||
EditorAgentMessagePersistResponse,
|
||||
EditorAgentMessagePlanRequest,
|
||||
EditorAgentMessageRequest,
|
||||
EditorAgentMessageResponse,
|
||||
} from '@/packages/shared/src/contracts';
|
||||
|
||||
import { isAbortError } from '../../../services/apiClient.ts';
|
||||
import {
|
||||
cancelEditorAgentToolCall,
|
||||
confirmEditorAgentToolCall,
|
||||
@@ -17,7 +20,8 @@ import {
|
||||
deleteEditorAgentConversation,
|
||||
getEditorAgentConversation,
|
||||
listEditorAgentConversations,
|
||||
sendEditorAgentMessage,
|
||||
persistEditorAgentMessage,
|
||||
planEditorAgentMessage,
|
||||
type SendEditorAgentMessageOptions,
|
||||
} from '../../../services/image-editor/editorAgentClient.ts';
|
||||
|
||||
@@ -35,9 +39,13 @@ export type EditorAgentConversationClient = {
|
||||
deleteConversation: (
|
||||
conversationId: string,
|
||||
) => Promise<EditorAgentConversationSummary>;
|
||||
sendMessage: (
|
||||
persistMessage: (
|
||||
conversationId: string,
|
||||
payload: EditorAgentMessageRequest,
|
||||
) => Promise<EditorAgentMessagePersistResponse>;
|
||||
planMessage: (
|
||||
conversationId: string,
|
||||
payload: EditorAgentMessagePlanRequest,
|
||||
options: SendEditorAgentMessageOptions,
|
||||
) => Promise<EditorAgentMessageResponse>;
|
||||
confirmToolCall: (conversationId: string, messageId: number) => Promise<void>;
|
||||
@@ -61,6 +69,7 @@ export type EditorAgentToolCallActionState = {
|
||||
type ActiveEditorAgentSend = {
|
||||
requestId: number;
|
||||
controller: AbortController;
|
||||
phase: 'creating' | 'persisting' | 'planning';
|
||||
};
|
||||
|
||||
const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
|
||||
@@ -68,7 +77,8 @@ const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
|
||||
createConversation: createEditorAgentConversation,
|
||||
getConversation: getEditorAgentConversation,
|
||||
deleteConversation: deleteEditorAgentConversation,
|
||||
sendMessage: sendEditorAgentMessage,
|
||||
persistMessage: persistEditorAgentMessage,
|
||||
planMessage: planEditorAgentMessage,
|
||||
confirmToolCall: confirmEditorAgentToolCall,
|
||||
cancelToolCall: cancelEditorAgentToolCall,
|
||||
};
|
||||
@@ -237,20 +247,14 @@ export function useEditorAgentConversation({
|
||||
);
|
||||
|
||||
const loadConversation = useCallback(
|
||||
async (
|
||||
conversationId: string,
|
||||
options: { showLoading?: boolean; reportError?: boolean } = {},
|
||||
) => {
|
||||
async (conversationId: string, options: { showLoading?: boolean } = {}) => {
|
||||
const requestId = conversationLoadRequestIdRef.current + 1;
|
||||
conversationLoadRequestIdRef.current = requestId;
|
||||
const showLoading = options.showLoading ?? true;
|
||||
const reportError = options.reportError ?? true;
|
||||
if (showLoading) {
|
||||
setIsLoadingMessages(true);
|
||||
}
|
||||
if (reportError) {
|
||||
setErrorMessage(null);
|
||||
}
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const detail = await client.getConversation(conversationId);
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
@@ -258,20 +262,14 @@ export function useEditorAgentConversation({
|
||||
}
|
||||
return detail;
|
||||
} catch (error) {
|
||||
if (
|
||||
reportError &&
|
||||
conversationLoadRequestIdRef.current === requestId
|
||||
) {
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : '读取画布 Agent 会话失败',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (
|
||||
showLoading &&
|
||||
conversationLoadRequestIdRef.current === requestId
|
||||
) {
|
||||
if (conversationLoadRequestIdRef.current === requestId) {
|
||||
setIsLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
@@ -445,27 +443,29 @@ export function useEditorAgentConversation({
|
||||
const requestedProjectId = normalizedProjectId;
|
||||
const requestId = pendingSendRequestIdRef.current + 1;
|
||||
const controller = new AbortController();
|
||||
pendingSendRequestIdRef.current = requestId;
|
||||
activeSendRef.current = {
|
||||
const activeSend: ActiveEditorAgentSend = {
|
||||
requestId,
|
||||
controller,
|
||||
phase: 'creating',
|
||||
};
|
||||
pendingSendRequestIdRef.current = requestId;
|
||||
activeSendRef.current = activeSend;
|
||||
setErrorMessage(null);
|
||||
setIsWaiting(true);
|
||||
setPatienceNoticeConversationId(null);
|
||||
let conversationId: string | null = null;
|
||||
let optimisticMessage: EditorAgentMessage | null = null;
|
||||
let userMessagePersisted = false;
|
||||
const clientMessageId = createEditorAgentClientMessageId();
|
||||
|
||||
try {
|
||||
conversationId = await ensureConversationForSend();
|
||||
if (
|
||||
pendingSendRequestIdRef.current !== requestId ||
|
||||
normalizedProjectIdRef.current !== requestedProjectId ||
|
||||
controller.signal.aborted
|
||||
normalizedProjectIdRef.current !== requestedProjectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const clientMessageId = createEditorAgentClientMessageId();
|
||||
const nextOptimisticMessage = createLocalUserMessage({
|
||||
id: -1,
|
||||
clientMessageId,
|
||||
@@ -477,18 +477,51 @@ export function useEditorAgentConversation({
|
||||
...currentMessages,
|
||||
nextOptimisticMessage,
|
||||
]);
|
||||
activeSend.phase = 'persisting';
|
||||
const persistResponse = await client.persistMessage(conversationId, {
|
||||
clientMessageId,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
userMessagePersisted = true;
|
||||
|
||||
if (
|
||||
pendingSendRequestIdRef.current !== requestId ||
|
||||
normalizedProjectIdRef.current !== requestedProjectId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setConversations((currentConversations) =>
|
||||
upsertConversationSummary(
|
||||
currentConversations,
|
||||
persistResponse.conversation,
|
||||
),
|
||||
);
|
||||
if (activeConversationIdRef.current === conversationId) {
|
||||
setMessages((currentMessages) =>
|
||||
currentMessages.map((message) =>
|
||||
message === optimisticMessage ||
|
||||
message.clientMessageId === clientMessageId
|
||||
? persistResponse.userMessage
|
||||
: message,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
activeSend.phase = 'planning';
|
||||
const pendingConversationId = conversationId;
|
||||
patienceNoticeTimerRef.current = setTimeout(() => {
|
||||
if (pendingSendRequestIdRef.current === requestId) {
|
||||
setPatienceNoticeConversationId(pendingConversationId);
|
||||
}
|
||||
}, EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
|
||||
const response = await client.sendMessage(
|
||||
const response = await client.planMessage(
|
||||
conversationId,
|
||||
{
|
||||
clientMessageId,
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
@@ -511,20 +544,13 @@ export function useEditorAgentConversation({
|
||||
applyDeltaMessages(response.deltaMessages);
|
||||
}
|
||||
} catch (error) {
|
||||
const wasStoppedByUser =
|
||||
const wasStoppedDuringPlanning =
|
||||
activeSend.phase === 'planning' &&
|
||||
stoppedSendRequestIdRef.current === requestId &&
|
||||
controller.signal.aborted;
|
||||
if (wasStoppedByUser) {
|
||||
controller.signal.aborted &&
|
||||
isAbortError(error);
|
||||
if (wasStoppedDuringPlanning) {
|
||||
setErrorMessage(null);
|
||||
if (
|
||||
conversationId &&
|
||||
activeConversationIdRef.current === conversationId
|
||||
) {
|
||||
await loadConversation(conversationId, {
|
||||
showLoading: false,
|
||||
reportError: false,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const message =
|
||||
@@ -535,14 +561,16 @@ export function useEditorAgentConversation({
|
||||
activeConversationIdRef.current === conversationId);
|
||||
if (shouldReportError) {
|
||||
setErrorMessage(message);
|
||||
if (optimisticMessage) {
|
||||
if (!userMessagePersisted && optimisticMessage) {
|
||||
setMessages((currentMessages) =>
|
||||
currentMessages.filter(
|
||||
(message) => message !== optimisticMessage,
|
||||
),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
if (!userMessagePersisted) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (pendingSendRequestIdRef.current === requestId) {
|
||||
@@ -569,7 +597,6 @@ export function useEditorAgentConversation({
|
||||
applyDeltaMessages,
|
||||
isLoadingConversations,
|
||||
isLoadingMessages,
|
||||
loadConversation,
|
||||
normalizedProjectId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -36,7 +36,8 @@ type EditorAgentGetConversation =
|
||||
EditorAgentConversationClient['getConversation'];
|
||||
type EditorAgentDeleteConversation =
|
||||
EditorAgentConversationClient['deleteConversation'];
|
||||
type EditorAgentStreamMessage = EditorAgentConversationClient['sendMessage'];
|
||||
type EditorAgentPersistMessage = EditorAgentConversationClient['persistMessage'];
|
||||
type EditorAgentPlanMessage = EditorAgentConversationClient['planMessage'];
|
||||
type EditorAgentConversationSummary = Awaited<
|
||||
ReturnType<EditorAgentListConversations>
|
||||
>[number];
|
||||
@@ -88,10 +89,32 @@ const deleteEditorAgentConversationMock = vi.hoisted(() =>
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
})),
|
||||
);
|
||||
const persistEditorAgentMessageMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
Parameters<EditorAgentPersistMessage>,
|
||||
ReturnType<EditorAgentPersistMessage>
|
||||
>(async (conversationId, payload) => ({
|
||||
conversation: {
|
||||
conversationId,
|
||||
projectId: 'editor-project-default',
|
||||
title: payload.text || '画布 Agent',
|
||||
updatedAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
userMessage: {
|
||||
id: 0,
|
||||
clientMessageId: payload.clientMessageId,
|
||||
role: 'user',
|
||||
text: payload.text,
|
||||
attachments: payload.attachments ?? [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
})),
|
||||
);
|
||||
const sendEditorAgentMessageMock = vi.hoisted(() =>
|
||||
vi.fn<
|
||||
Parameters<EditorAgentStreamMessage>,
|
||||
ReturnType<EditorAgentStreamMessage>
|
||||
Parameters<EditorAgentPlanMessage>,
|
||||
ReturnType<EditorAgentPlanMessage>
|
||||
>(async () => ({
|
||||
conversation: {
|
||||
conversationId: 'editor-agent-conv-test',
|
||||
@@ -168,7 +191,8 @@ vi.mock('../../services/image-editor/editorAgentClient', () => ({
|
||||
deleteEditorAgentConversation: deleteEditorAgentConversationMock,
|
||||
getEditorAgentConversation: getEditorAgentConversationMock,
|
||||
listEditorAgentConversations: listEditorAgentConversationsMock,
|
||||
sendEditorAgentMessage: sendEditorAgentMessageMock,
|
||||
persistEditorAgentMessage: persistEditorAgentMessageMock,
|
||||
planEditorAgentMessage: sendEditorAgentMessageMock,
|
||||
}));
|
||||
|
||||
function createEditorAgentConversationSummary(
|
||||
@@ -315,6 +339,20 @@ describe('ImageCanvasEditorView', () => {
|
||||
deleteEditorAgentConversationMock.mockResolvedValue(
|
||||
createEditorAgentConversationSummary(),
|
||||
);
|
||||
persistEditorAgentMessageMock.mockImplementation(
|
||||
async (conversationId, payload) => ({
|
||||
conversation: createEditorAgentConversationSummary({ conversationId }),
|
||||
userMessage: {
|
||||
id: 0,
|
||||
clientMessageId: payload.clientMessageId,
|
||||
role: 'user',
|
||||
text: payload.text,
|
||||
attachments: payload.attachments ?? [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
);
|
||||
sendEditorAgentMessageMock.mockResolvedValue({
|
||||
conversation: createEditorAgentConversationSummary(),
|
||||
deltaMessages: [],
|
||||
@@ -376,6 +414,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
getEditorAgentConversationMock.mockReset();
|
||||
deleteEditorAgentConversationMock.mockReset();
|
||||
sendEditorAgentMessageMock.mockReset();
|
||||
persistEditorAgentMessageMock.mockReset();
|
||||
confirmEditorAgentToolCallMock.mockReset();
|
||||
cancelEditorAgentToolCallMock.mockReset();
|
||||
getPlatformProfileDashboardMock.mockReset();
|
||||
|
||||
@@ -668,13 +668,12 @@ describe('apiClient', () => {
|
||||
);
|
||||
|
||||
const request = requestJson(
|
||||
'/api/editor/agent-conversations/conversation-1/messages',
|
||||
'/api/editor/agent-conversations/conversation-1/messages/plan',
|
||||
{
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
clientMessageId: 'client-message-aborted',
|
||||
text: '中断这一轮',
|
||||
}),
|
||||
},
|
||||
'发送画布 Agent 消息失败',
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
deleteEditorAgentConversation,
|
||||
getEditorAgentConversation,
|
||||
listEditorAgentConversations,
|
||||
sendEditorAgentMessage,
|
||||
persistEditorAgentMessage,
|
||||
planEditorAgentMessage,
|
||||
} from './editorAgentClient';
|
||||
|
||||
const requestJsonMock = vi.hoisted(() => vi.fn());
|
||||
@@ -100,8 +101,25 @@ describe('editorAgentClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sends an editor agent message and returns delta messages', async () => {
|
||||
const responseBody = {
|
||||
it('persists an editor agent message before starting abortable planning', async () => {
|
||||
const persistResponse = {
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
title: '帮我把角色改成像素风',
|
||||
updatedAt: '2026-07-03T00:00:00.500Z',
|
||||
},
|
||||
userMessage: {
|
||||
id: 0,
|
||||
clientMessageId: 'client-message-1',
|
||||
role: 'user',
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
toolCall: null,
|
||||
createdAt: '2026-07-03T00:00:00.500Z',
|
||||
},
|
||||
};
|
||||
const planResponse = {
|
||||
conversation: {
|
||||
conversationId: 'conversation-1',
|
||||
projectId: 'project-1',
|
||||
@@ -120,21 +138,29 @@ describe('editorAgentClient', () => {
|
||||
],
|
||||
errorMessage: null,
|
||||
};
|
||||
requestJsonMock.mockResolvedValueOnce(responseBody);
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce(persistResponse)
|
||||
.mockResolvedValueOnce(planResponse);
|
||||
const controller = new AbortController();
|
||||
|
||||
const result = await sendEditorAgentMessage(
|
||||
const persisted = await persistEditorAgentMessage(
|
||||
'conversation-1',
|
||||
{
|
||||
clientMessageId: 'client-message-1',
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
},
|
||||
);
|
||||
const planned = await planEditorAgentMessage(
|
||||
'conversation-1',
|
||||
{ clientMessageId: 'client-message-1' },
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
expect(result).toEqual(responseBody);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
expect(persisted).toEqual(persistResponse);
|
||||
expect(planned).toEqual(planResponse);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/editor/agent-conversations/conversation-1/messages',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
@@ -144,9 +170,29 @@ describe('editorAgentClient', () => {
|
||||
text: '帮我把角色改成像素风',
|
||||
attachments: [],
|
||||
}),
|
||||
}),
|
||||
'保存画布 Agent 消息失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 60_000,
|
||||
authImpact: 'local',
|
||||
retry: {
|
||||
maxRetries: 1,
|
||||
baseDelayMs: 250,
|
||||
maxDelayMs: 250,
|
||||
retryUnsafeMethods: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(requestJsonMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/editor/agent-conversations/conversation-1/messages/plan',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clientMessageId: 'client-message-1' }),
|
||||
signal: controller.signal,
|
||||
}),
|
||||
'发送画布 Agent 消息失败',
|
||||
'规划画布 Agent 消息失败',
|
||||
expect.objectContaining({
|
||||
timeoutMs: 1_200_000,
|
||||
authImpact: 'local',
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
EditorAgentConversationListResponse,
|
||||
EditorAgentConversationResponse,
|
||||
EditorAgentConversationSummary,
|
||||
EditorAgentMessagePersistResponse,
|
||||
EditorAgentMessagePlanRequest,
|
||||
EditorAgentMessageRequest,
|
||||
EditorAgentMessageResponse,
|
||||
} from '../../../packages/shared/src/contracts/editorAgent';
|
||||
@@ -11,6 +13,7 @@ import { requestJson } from '../apiClient';
|
||||
|
||||
const EDITOR_PROJECT_AGENT_CONVERSATION_API_BASE = '/api/editor/projects';
|
||||
const EDITOR_AGENT_CONVERSATION_API_BASE = '/api/editor/agent-conversations';
|
||||
const EDITOR_AGENT_MESSAGE_PERSIST_TIMEOUT_MS = 60_000;
|
||||
const EDITOR_AGENT_MESSAGE_TIMEOUT_MS = 1_200_000;
|
||||
const EDITOR_AGENT_MESSAGE_RETRY = {
|
||||
maxRetries: 1,
|
||||
@@ -107,18 +110,34 @@ export async function deleteEditorAgentConversation(
|
||||
return response.conversation;
|
||||
}
|
||||
|
||||
export async function sendEditorAgentMessage(
|
||||
export async function persistEditorAgentMessage(
|
||||
conversationId: string,
|
||||
payload: EditorAgentMessageRequest,
|
||||
): Promise<EditorAgentMessagePersistResponse> {
|
||||
return requestJson<EditorAgentMessagePersistResponse>(
|
||||
`${agentConversationPath(conversationId)}/messages`,
|
||||
jsonRequest('POST', payload as unknown as Record<string, unknown>),
|
||||
'保存画布 Agent 消息失败',
|
||||
{
|
||||
timeoutMs: EDITOR_AGENT_MESSAGE_PERSIST_TIMEOUT_MS,
|
||||
authImpact: 'local',
|
||||
retry: EDITOR_AGENT_MESSAGE_RETRY,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function planEditorAgentMessage(
|
||||
conversationId: string,
|
||||
payload: EditorAgentMessagePlanRequest,
|
||||
options: SendEditorAgentMessageOptions = {},
|
||||
): Promise<EditorAgentMessageResponse> {
|
||||
return requestJson<EditorAgentMessageResponse>(
|
||||
`${agentConversationPath(conversationId)}/messages`,
|
||||
`${agentConversationPath(conversationId)}/messages/plan`,
|
||||
{
|
||||
...jsonRequest('POST', payload as unknown as Record<string, unknown>),
|
||||
signal: options.signal,
|
||||
},
|
||||
'发送画布 Agent 消息失败',
|
||||
'规划画布 Agent 消息失败',
|
||||
{
|
||||
timeoutMs: EDITOR_AGENT_MESSAGE_TIMEOUT_MS,
|
||||
authImpact: 'local',
|
||||
|
||||
Reference in New Issue
Block a user