优化美术 Agent 长等待提示与超时处理

将 120 秒硬超时改为请求存活时的耐心等待提示。
收口 provider 安全上限、有限重试和明确失败错误。
补齐等待互斥、计时清理、前后端测试及契约文档。
This commit is contained in:
2026-07-21 16:05:52 +08:00
parent 8bdc728fd3
commit 48c9ee2fae
12 changed files with 430 additions and 82 deletions
@@ -3234,6 +3234,14 @@
- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”和“普通连续工具仍会触发 max-turn 门禁”。 - 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”和“普通连续工具仍会触发 max-turn 门禁”。
- 关联:`server-rs/crates/platform-editor-agent/src/framework/run.rs``server-rs/crates/platform-editor-agent/src/framework/tool.rs``server-rs/crates/platform-editor-agent/src/agent/tools/` - 关联:`server-rs/crates/platform-editor-agent/src/framework/run.rs``server-rs/crates/platform-editor-agent/src/framework/tool.rs``server-rs/crates/platform-editor-agent/src/agent/tools/`
## 画布 Agent 的规划请求不能关闭瞬时失败重试
- 现象:美术 Agent 对话返回红色错误气泡 `completion error: LLM 请求超时,累计尝试 1 次`;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。
- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client``max_retries` 硬编码为 0VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。
- 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒,保证理论上限小于前端 20 分钟 transport timeout;响应头后的体读取/解析错误按明确失败收口。规划错误对用户统一为中文。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`api-server` AppState 测试锁定专用 client 透传 retry 次数。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs``server-rs/crates/platform-editor-agent/src/framework/error.rs``server-rs/crates/api-server/src/state.rs``src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts``src/components/image-editor/EditorAgentConversation/MessageBubble.tsx``src/services/image-editor/editorAgentClient.ts`
## 前端退役目录不能只靠扫描和 ignore 隔离 ## 前端退役目录不能只靠扫描和 ignore 隔离
- 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。 - 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。
@@ -75,7 +75,8 @@ npm run check:server-rs-ddd
- `/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` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。 - `/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` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 - `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 访问。 - `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` 保持为空;前端隐藏前缀并显示红色错误气泡,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。工具失败同样必须形成可回读记录,不能只返回瞬时错误。 - 完整消息文档存 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 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
- 画布 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 秒,使两次 attempt 的理论上限仍早于前端消息 POST 的 20 分钟 timeout;已收到成功响应头后的响应体读取或解析失败直接按明确失败收口。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints` - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`
- `/messages/{messageId}/confirm``/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 - `/messages/{messageId}/confirm``/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
@@ -95,12 +95,12 @@
## LLM 与计费 ## LLM 与计费
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 注册八类工具。 - 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 注册八类工具。
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文 - 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 - 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。 - 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image` - 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet` - 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`
- 画布 Agent 规划请求使用 Chat Completions1024 `max_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费。 - 画布 Agent 规划请求使用 Chat Completions1024 `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 秒,使两次 attempt 的理论最坏等待仍早于前端 20 分钟 transport timeout;已收到成功响应头后的响应体读取或解析失败直接按明确失败收口。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。 - function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。 - **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。 - 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
@@ -115,7 +115,7 @@
4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层); 4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层);
5. 生成中的进行中动画; 5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因); 6. 错误气泡(失败/余额不足,带原因);
7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。 7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,超过 120 秒但 POST 仍 pending 时在思考气泡中显示“仍在处理中,请耐心等待”,最终成功或失败后自动移除,避免后端已持久化消息但前端中断请求后产生会话状态错位。
不做(明确排除,防止后人补齐): 不做(明确排除,防止后人补齐):
+10 -2
View File
@@ -48,6 +48,8 @@ use crate::work_author::{
}; };
const ADMIN_ROLE: &str = "admin"; const ADMIN_ROLE: &str = "admin";
const EDITOR_AGENT_LLM_MAX_RETRIES: u32 = 1;
const EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS: u64 = 60_000;
pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8; pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8;
pub type HttpRequestPermitPool = Semaphore; pub type HttpRequestPermitPool = Semaphore;
@@ -2084,8 +2086,10 @@ fn build_editor_agent_llm_client(
api_key.to_string(), api_key.to_string(),
platform_llm::EDITOR_AGENT_GPT5_MODEL.to_string(), platform_llm::EDITOR_AGENT_GPT5_MODEL.to_string(),
config.llm_request_timeout_ms, config.llm_request_timeout_ms,
0, config.llm_max_retries.min(EDITOR_AGENT_LLM_MAX_RETRIES),
config.llm_retry_backoff_ms, config
.llm_retry_backoff_ms
.min(EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS),
)?; )?;
Ok(Some(LlmClient::new(llm_config)?)) Ok(Some(LlmClient::new(llm_config)?))
@@ -2354,6 +2358,8 @@ mod tests {
fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() { fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() {
let mut config = AppConfig::default(); let mut config = AppConfig::default();
config.llm_api_key = None; config.llm_api_key = None;
config.llm_max_retries = 2;
config.llm_retry_backoff_ms = 120_000;
config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); config.vector_engine_base_url = "https://api.vectorengine.test".to_string();
config.vector_engine_api_key = Some("ve-key".to_string()); config.vector_engine_api_key = Some("ve-key".to_string());
@@ -2371,6 +2377,8 @@ mod tests {
"https://api.vectorengine.test/v1/chat/completions" "https://api.vectorengine.test/v1/chat/completions"
); );
assert!(!client.config().official_fallback()); assert!(!client.config().official_fallback());
assert_eq!(client.config().max_retries(), 1);
assert_eq!(client.config().retry_backoff_ms(), 60_000);
} }
fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot { fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot {
@@ -8,7 +8,7 @@ use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmTextReques
use serde_json::Value; use serde_json::Value;
const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024;
const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000; const EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS: u64 = 480_000;
pub struct LlmCompletionModel { pub struct LlmCompletionModel {
client: LlmClient, client: LlmClient,
@@ -41,7 +41,7 @@ fn build_editor_agent_llm_request(messages: Vec<LlmMessage>) -> LlmTextRequest {
LlmTextRequest::new(messages) LlmTextRequest::new(messages)
.with_model(EDITOR_AGENT_GPT5_MODEL) .with_model(EDITOR_AGENT_GPT5_MODEL)
.with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS) .with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS)
.with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) .with_request_timeout_ms(EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS)
} }
pub struct LlmChatAgentBuilder { pub struct LlmChatAgentBuilder {
@@ -188,7 +188,7 @@ mod tests {
assert_eq!(request.model.as_deref(), Some(EDITOR_AGENT_GPT5_MODEL)); assert_eq!(request.model.as_deref(), Some(EDITOR_AGENT_GPT5_MODEL));
assert_eq!(request.max_tokens, Some(1024)); assert_eq!(request.max_tokens, Some(1024));
assert_eq!(request.request_timeout_ms, Some(60_000)); assert_eq!(request.request_timeout_ms, Some(480_000));
assert_eq!(request.messages.len(), 2); assert_eq!(request.messages.len(), 2);
} }
} }
@@ -9,14 +9,45 @@ pub enum PromptError {
impl std::fmt::Display for PromptError { impl std::fmt::Display for PromptError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::CompletionError(msg) => write!(f, "completion error: {msg}"), Self::CompletionError(msg) => write!(f, "美术 Agent 规划失败:{msg}"),
Self::ToolError(msg) => write!(f, "tool error: {msg}"), Self::ToolError(msg) => write!(f, "美术 Agent 工具执行失败:{msg}"),
Self::InternalError(msg) => write!(f, "internal error: {msg}"), Self::InternalError(msg) => write!(f, "美术 Agent 内部错误:{msg}"),
Self::MaxTurnsReached { max_turns } => { Self::MaxTurnsReached { max_turns } => {
write!(f, "max turns reached: {max_turns}") write!(f, "美术 Agent 规划轮数已达上限:{max_turns}")
} }
} }
} }
} }
impl std::error::Error for PromptError {} impl std::error::Error for PromptError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completion_error_uses_user_facing_chinese_copy() {
let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string());
assert_eq!(
error.to_string(),
"美术 Agent 规划失败:LLM 请求超时,累计尝试 2 次"
);
}
#[test]
fn other_errors_do_not_expose_framework_prefixes() {
assert_eq!(
PromptError::ToolError("参数无效".to_string()).to_string(),
"美术 Agent 工具执行失败:参数无效"
);
assert_eq!(
PromptError::InternalError("序列化失败".to_string()).to_string(),
"美术 Agent 内部错误:序列化失败"
);
assert_eq!(
PromptError::MaxTurnsReached { max_turns: 3 }.to_string(),
"美术 Agent 规划轮数已达上限:3"
);
}
}
@@ -8,13 +8,14 @@ import {
waitFor, waitFor,
within, within,
} from '@testing-library/react'; } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { import type {
EditorAgentMessage, EditorAgentMessage,
EditorAgentMessageResponse, EditorAgentMessageResponse,
} from '@/packages/shared/src/contracts'; } from '@/packages/shared/src/contracts';
import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts';
import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts'; import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts';
import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx'; import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx';
@@ -114,6 +115,10 @@ function createClient(): EditorAgentConversationClient {
}; };
} }
afterEach(() => {
vi.useRealTimers();
});
function createPendingToolCallMessage(): EditorAgentMessage { function createPendingToolCallMessage(): EditorAgentMessage {
return { return {
id: 2, id: 2,
@@ -331,18 +336,26 @@ describe('EditorAgentConversationPanelView', () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('已经看到画布内容')).toBeTruthy(); expect(screen.getByText('已经看到画布内容')).toBeTruthy();
}); });
vi.useFakeTimers();
fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { fireEvent.change(screen.getByLabelText('发送给画布 Agent'), {
target: { value: '继续规划' }, target: { value: '继续规划' },
}); });
fireEvent.click(screen.getByRole('button', { name: '发送' })); fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => { await act(async () => {
expect( await Promise.resolve();
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
}); });
expect(
screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'),
).toBe(true);
expect(screen.queryByRole('button', { name: '停止' })).toBeNull(); expect(screen.queryByRole('button', { name: '停止' })).toBeNull();
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy();
await act(async () => { await act(async () => {
resolveSend({ resolveSend({
@@ -355,7 +368,9 @@ describe('EditorAgentConversationPanelView', () => {
deltaMessages: [], deltaMessages: [],
errorMessage: null, errorMessage: null,
}); });
await Promise.resolve();
}); });
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
}); });
it('uploads pasted images as canvas attachments before sending', async () => { it('uploads pasted images as canvas attachments before sending', async () => {
@@ -431,9 +446,7 @@ describe('EditorAgentConversationPanelView', () => {
); );
}); });
expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy(); expect(screen.getByRole('option', { name: '角色参考' })).toBeTruthy();
expect( expect(screen.queryByRole('option', { name: 'conversation-1' })).toBeNull();
screen.queryByRole('option', { name: 'conversation-1' }),
).toBeNull();
}); });
it('sends selected attachments even when the text input is empty', async () => { it('sends selected attachments even when the text input is empty', async () => {
@@ -555,9 +568,9 @@ describe('EditorAgentConversationPanelView', () => {
).toBe('失败后恢复这条草稿'); ).toBe('失败后恢复这条草稿');
expect(screen.getByText('角色图层')).toBeTruthy(); expect(screen.getByText('角色图层')).toBeTruthy();
expect( expect(
within(screen.getByRole('log', { name: '画布 Agent 消息流' })).queryByText( within(
'失败后恢复这条草稿', screen.getByRole('log', { name: '画布 Agent 消息流' }),
), ).queryByText('失败后恢复这条草稿'),
).toBeNull(); ).toBeNull();
}); });
@@ -692,9 +705,9 @@ describe('EditorAgentConversationPanelView', () => {
}); });
expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy(); expect(screen.getByRole('button', { name: '执行中' })).toBeTruthy();
expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); expect(screen.queryByRole('button', { name: '确认' })).toBeNull();
expect(screen.getByRole('button', { name: '取消' }).hasAttribute('disabled')).toBe( expect(
true, screen.getByRole('button', { name: '取消' }).hasAttribute('disabled'),
); ).toBe(true);
await act(async () => { await act(async () => {
resolveConfirmation(); resolveConfirmation();
@@ -258,6 +258,7 @@ export function EditorAgentConversationPanelView({
isCreatingConversation, isCreatingConversation,
isDeletingConversation, isDeletingConversation,
isWaiting, isWaiting,
isPatienceNoticeVisible,
toolCallAction, toolCallAction,
isToolCallActionPending, isToolCallActionPending,
errorMessage, errorMessage,
@@ -614,7 +615,9 @@ export function EditorAgentConversationPanelView({
}} }}
/> />
))} ))}
{isWaiting ? <ThinkingBubble /> : null} {isWaiting ? (
<ThinkingBubble showPatienceNotice={isPatienceNoticeVisible} />
) : null}
</> </>
) : ( ) : (
<div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400"> <div className="rounded-3xl border border-dashed border-slate-200 bg-white/70 px-4 py-8 text-center text-sm text-slate-400">
@@ -5,7 +5,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { EditorAgentMessage } from '@/packages/shared/src/contracts'; import type { EditorAgentMessage } from '@/packages/shared/src/contracts';
import { MessageBubble } from './MessageBubble.tsx'; import { MessageBubble, ThinkingBubble } from './MessageBubble.tsx';
function renderMessage(message: EditorAgentMessage) { function renderMessage(message: EditorAgentMessage) {
return render( return render(
@@ -19,6 +19,18 @@ function renderMessage(message: EditorAgentMessage) {
} }
describe('MessageBubble', () => { describe('MessageBubble', () => {
it('shows a patience notice only for an extended pending request', () => {
const { rerender } = render(<ThinkingBubble />);
expect(screen.getByLabelText('Agent思考中')).toBeTruthy();
expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull();
rerender(<ThinkingBubble showPatienceNotice />);
expect(screen.getByLabelText('Agent仍在处理中')).toBeTruthy();
expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy();
});
it('shows prefixed system errors as red Agent errors without the wire prefix', () => { it('shows prefixed system errors as red Agent errors without the wire prefix', () => {
renderMessage({ renderMessage({
id: 2, id: 2,
@@ -14,9 +14,16 @@ function messageRoleLabel(role: EditorAgentMessage['role']) {
return 'Agent'; return 'Agent';
} }
export function ThinkingBubble() { export function ThinkingBubble({
showPatienceNotice = false,
}: {
showPatienceNotice?: boolean;
}) {
return ( return (
<article className="flex justify-start" aria-label="Agent思考中"> <article
className="flex justify-start"
aria-label={showPatienceNotice ? 'Agent仍在处理中' : 'Agent思考中'}
>
<div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm"> <div className="max-w-[86%] rounded-3xl border border-slate-200 bg-white px-3.5 py-3 text-sm leading-6 shadow-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="flex gap-0.5"> <span className="flex gap-0.5">
@@ -33,6 +40,9 @@ export function ThinkingBubble() {
style={{ animationDelay: '300ms' }} style={{ animationDelay: '300ms' }}
/> />
</span> </span>
{showPatienceNotice ? (
<span className="text-slate-600"></span>
) : null}
</div> </div>
</div> </div>
</article> </article>
@@ -61,7 +71,11 @@ export function MessageBubble({
? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length) ? message.text.slice(EDITOR_AGENT_ERROR_MESSAGE_PREFIX.length)
: null; : null;
if (message.role === 'system' && !message.toolCall && systemErrorText === null) { if (
message.role === 'system' &&
!message.toolCall &&
systemErrorText === null
) {
return null; return null;
} }
if ( if (
@@ -1,7 +1,7 @@
/* @vitest-environment jsdom */ /* @vitest-environment jsdom */
import { act, renderHook, waitFor } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { import type {
EditorAgentConversationDetail, EditorAgentConversationDetail,
@@ -9,6 +9,7 @@ import type {
EditorAgentMessageResponse, EditorAgentMessageResponse,
} from '../../../../packages/shared/src/contracts/editorAgent.ts'; } from '../../../../packages/shared/src/contracts/editorAgent.ts';
import { import {
EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS,
type EditorAgentConversationClient, type EditorAgentConversationClient,
useEditorAgentConversation, useEditorAgentConversation,
} from './useEditorAgentConversation.ts'; } from './useEditorAgentConversation.ts';
@@ -116,6 +117,10 @@ describe('useEditorAgentConversation', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
afterEach(() => {
vi.useRealTimers();
});
it('loads conversations and applies delta messages', async () => { it('loads conversations and applies delta messages', async () => {
const client = createClient(); const client = createClient();
const onCanvasRefreshRequested = vi.fn(); const onCanvasRefreshRequested = vi.fn();
@@ -445,6 +450,87 @@ describe('useEditorAgentConversation', () => {
); );
}); });
it('does not send or apply a stale conversation created after switching projects', async () => {
const client = createClient();
let resolveCreate!: (detail: EditorAgentConversationDetail) => void;
vi.mocked(client.listConversations)
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
{
conversationId: 'conversation-project-2',
projectId: 'project-2',
title: '项目二会话',
updatedAt: '2026-07-03T00:02:00.000Z',
},
]);
vi.mocked(client.createConversation).mockImplementationOnce(
() =>
new Promise<EditorAgentConversationDetail>((resolve) => {
resolveCreate = resolve;
}),
);
vi.mocked(client.getConversation).mockResolvedValueOnce({
conversationId: 'conversation-project-2',
projectId: 'project-2',
title: '项目二会话',
messages: [
{
id: 20,
role: 'assistant',
text: '项目二消息',
attachments: [],
toolCall: null,
createdAt: '2026-07-03T00:02:00.000Z',
},
],
createdAt: '2026-07-03T00:02:00.000Z',
updatedAt: '2026-07-03T00:02:00.000Z',
});
const { result, rerender } = renderHook(
({ projectId }) => useEditorAgentConversation({ projectId, client }),
{ initialProps: { projectId: 'project-1' } },
);
await waitFor(() => {
expect(result.current.isLoadingConversations).toBe(false);
});
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('旧项目消息');
});
await waitFor(() => {
expect(client.createConversation).toHaveBeenCalledWith('project-1', {});
});
rerender({ projectId: 'project-2' });
await waitFor(() => {
expect(result.current.activeConversationId).toBe(
'conversation-project-2',
);
});
await act(async () => {
resolveCreate({
conversationId: 'conversation-project-1',
projectId: 'project-1',
title: '旧项目新会话',
messages: [],
createdAt: '2026-07-03T00:01:00.000Z',
updatedAt: '2026-07-03T00:01:00.000Z',
});
await sendPromise;
});
expect(client.sendMessage).not.toHaveBeenCalled();
expect(result.current.activeConversationId).toBe('conversation-project-2');
expect(result.current.messages.map((message) => message.text)).toEqual([
'项目二消息',
]);
expect(result.current.isWaiting).toBe(false);
expect(result.current.isPatienceNoticeVisible).toBe(false);
});
it('allows sending an attachment-only message', async () => { it('allows sending an attachment-only message', async () => {
const client = createClient(); const client = createClient();
const { result } = renderHook(() => const { result } = renderHook(() =>
@@ -769,7 +855,9 @@ describe('useEditorAgentConversation', () => {
); );
await waitFor(() => { await waitFor(() => {
expect(result.current.messages[0]?.toolCall?.status).toBe('not_completed'); expect(result.current.messages[0]?.toolCall?.status).toBe(
'not_completed',
);
}); });
const getConversationCallsBeforeCancel = vi.mocked(client.getConversation) const getConversationCallsBeforeCancel = vi.mocked(client.getConversation)
.mock.calls.length; .mock.calls.length;
@@ -809,6 +897,78 @@ describe('useEditorAgentConversation', () => {
expect(result.current.messages).toHaveLength(0); expect(result.current.messages).toHaveLength(0);
}); });
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(
() =>
new Promise<EditorAgentMessageResponse>((_resolve, reject) => {
rejectSend = reject;
}),
);
const { result } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
vi.useFakeTimers();
let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('请继续');
});
await act(async () => {
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(result.current.isPatienceNoticeVisible).toBe(true);
await act(async () => {
rejectSend(new Error('LLM 连接已断开'));
await sendPromise.catch(() => undefined);
});
expect(result.current.isPatienceNoticeVisible).toBe(false);
expect(result.current.isWaiting).toBe(false);
expect(result.current.errorMessage).toBe('LLM 连接已断开');
expect(result.current.messages).toHaveLength(0);
});
it('cleans the patience timer when the hook unmounts', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockImplementation(
() => new Promise<EditorAgentMessageResponse>(() => undefined),
);
const { result, unmount } = renderHook(() =>
useEditorAgentConversation({ projectId: 'project-1', client }),
);
await waitFor(() => {
expect(result.current.activeConversation?.conversationId).toBe(
'conversation-1',
);
});
vi.useFakeTimers();
act(() => {
void result.current.sendMessage('请继续');
});
await act(async () => {
await Promise.resolve();
});
expect(vi.getTimerCount()).toBe(1);
unmount();
expect(vi.getTimerCount()).toBe(0);
});
it('keeps the active request pending without exposing a stop action', async () => { it('keeps the active request pending without exposing a stop action', async () => {
const client = createClient(); const client = createClient();
let capturedSignal: AbortSignal | null = null; let capturedSignal: AbortSignal | null = null;
@@ -830,16 +990,27 @@ describe('useEditorAgentConversation', () => {
); );
}); });
void act(() => { vi.useFakeTimers();
void result.current.sendMessage('请继续'); let sendPromise!: Promise<void>;
act(() => {
sendPromise = result.current.sendMessage('请继续');
void result.current.sendMessage('不要重复发送');
}); });
await waitFor(() => { await act(async () => {
expect(result.current.isWaiting).toBe(true); await Promise.resolve();
}); });
expect(result.current.isWaiting).toBe(true);
expect(result.current.isPatienceNoticeVisible).toBe(false);
expect(client.sendMessage).toHaveBeenCalledTimes(1);
expect(capturedSignal).toBeNull(); expect(capturedSignal).toBeNull();
expect('stopCurrentTurn' in result.current).toBe(false); expect('stopCurrentTurn' in result.current).toBe(false);
act(() => {
vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS);
});
expect(result.current.isPatienceNoticeVisible).toBe(true);
await act(async () => { await act(async () => {
resolveSend({ resolveSend({
conversation: { conversation: {
@@ -851,10 +1022,10 @@ describe('useEditorAgentConversation', () => {
deltaMessages: [], deltaMessages: [],
errorMessage: null, errorMessage: null,
}); });
await sendPromise;
}); });
await waitFor(() => { expect(result.current.isWaiting).toBe(false);
expect(result.current.isWaiting).toBe(false); expect(result.current.isPatienceNoticeVisible).toBe(false);
});
}); });
}); });
@@ -40,14 +40,8 @@ export type EditorAgentConversationClient = {
payload: EditorAgentMessageRequest, payload: EditorAgentMessageRequest,
options: SendEditorAgentMessageOptions, options: SendEditorAgentMessageOptions,
) => Promise<EditorAgentMessageResponse>; ) => Promise<EditorAgentMessageResponse>;
confirmToolCall: ( confirmToolCall: (conversationId: string, messageId: number) => Promise<void>;
conversationId: string, cancelToolCall: (conversationId: string, messageId: number) => Promise<void>;
messageId: number,
) => Promise<void>;
cancelToolCall: (
conversationId: string,
messageId: number,
) => Promise<void>;
}; };
type UseEditorAgentConversationOptions = { type UseEditorAgentConversationOptions = {
@@ -74,6 +68,8 @@ const defaultEditorAgentConversationClient: EditorAgentConversationClient = {
cancelToolCall: cancelEditorAgentToolCall, cancelToolCall: cancelEditorAgentToolCall,
}; };
export const EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS = 120_000;
function createEditorAgentClientMessageId() { function createEditorAgentClientMessageId() {
const randomId = const randomId =
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
@@ -155,16 +151,51 @@ export function useEditorAgentConversation({
const [isCreatingConversation, setIsCreatingConversation] = useState(false); const [isCreatingConversation, setIsCreatingConversation] = useState(false);
const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [isDeletingConversation, setIsDeletingConversation] = useState(false);
const [isWaiting, setIsWaiting] = useState(false); const [isWaiting, setIsWaiting] = useState(false);
const [patienceNoticeConversationId, setPatienceNoticeConversationId] =
useState<string | null>(null);
const [toolCallAction, setToolCallAction] = const [toolCallAction, setToolCallAction] =
useState<EditorAgentToolCallActionState>(null); useState<EditorAgentToolCallActionState>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const normalizedProjectIdRef = useRef(normalizedProjectId);
const activeConversationIdRef = useRef<string | null>(null); const activeConversationIdRef = useRef<string | null>(null);
const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null); const activeToolCallActionRef = useRef<EditorAgentToolCallActionState>(null);
const conversationLoadRequestIdRef = useRef(0); const conversationLoadRequestIdRef = useRef(0);
const createConversationRequestIdRef = useRef(0);
const isWaitingRef = useRef(false);
const pendingSendRequestIdRef = useRef(0);
const patienceNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
useEffect(() => { useEffect(() => {
activeConversationIdRef.current = activeConversationId; activeConversationIdRef.current = activeConversationId;
}, [activeConversationId]); }, [activeConversationId]);
normalizedProjectIdRef.current = normalizedProjectId;
useEffect(() => {
return () => {
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
};
}, []);
useEffect(() => {
pendingSendRequestIdRef.current += 1;
createConversationRequestIdRef.current += 1;
isWaitingRef.current = false;
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
setIsWaiting(false);
setIsCreatingConversation(false);
setPatienceNoticeConversationId(null);
}, [normalizedProjectId]);
const activeConversation = useMemo( const activeConversation = useMemo(
() => () =>
@@ -193,10 +224,7 @@ export function useEditorAgentConversation({
); );
const loadConversation = useCallback( const loadConversation = useCallback(
async ( async (conversationId: string, options: { showLoading?: boolean } = {}) => {
conversationId: string,
options: { showLoading?: boolean } = {},
) => {
const requestId = conversationLoadRequestIdRef.current + 1; const requestId = conversationLoadRequestIdRef.current + 1;
conversationLoadRequestIdRef.current = requestId; conversationLoadRequestIdRef.current = requestId;
const showLoading = options.showLoading ?? true; const showLoading = options.showLoading ?? true;
@@ -287,19 +315,37 @@ export function useEditorAgentConversation({
if (!normalizedProjectId) { if (!normalizedProjectId) {
throw new Error('缺少画布项目 ID'); throw new Error('缺少画布项目 ID');
} }
const requestedProjectId = normalizedProjectId;
const requestId = createConversationRequestIdRef.current + 1;
createConversationRequestIdRef.current = requestId;
setIsCreatingConversation(true); setIsCreatingConversation(true);
setErrorMessage(null); setErrorMessage(null);
try { try {
const detail = await client.createConversation(normalizedProjectId, {}); const detail = await client.createConversation(requestedProjectId, {});
applyConversationDetail(detail); if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
applyConversationDetail(detail);
}
return detail; return detail;
} catch (error) { } catch (error) {
setErrorMessage( if (
error instanceof Error ? error.message : '创建画布 Agent 会话失败', createConversationRequestIdRef.current === requestId &&
); normalizedProjectIdRef.current === requestedProjectId
) {
setErrorMessage(
error instanceof Error ? error.message : '创建画布 Agent 会话失败',
);
}
throw error; throw error;
} finally { } finally {
setIsCreatingConversation(false); if (
createConversationRequestIdRef.current === requestId &&
normalizedProjectIdRef.current === requestedProjectId
) {
setIsCreatingConversation(false);
}
} }
}, [applyConversationDetail, client, normalizedProjectId]); }, [applyConversationDetail, client, normalizedProjectId]);
@@ -328,9 +374,9 @@ export function useEditorAgentConversation({
const toolCall = message.toolCall; const toolCall = message.toolCall;
return Boolean( return Boolean(
toolCall?.externalJobId && toolCall?.externalJobId &&
(toolCall.images.length > 0 || (toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 || (toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0), (toolCall.audios?.length ?? 0) > 0),
); );
}) })
) { ) {
@@ -363,29 +409,49 @@ export function useEditorAgentConversation({
const text = rawText.trim(); const text = rawText.trim();
if ( if (
(!text && !attachments.length) || (!text && !attachments.length) ||
isWaiting || isWaitingRef.current ||
activeToolCallActionRef.current !== null || activeToolCallActionRef.current !== null ||
isLoadingConversations || isLoadingConversations ||
isLoadingMessages isLoadingMessages
) { ) {
return; return;
} }
const conversationId = await ensureConversationForSend(); isWaitingRef.current = true;
const clientMessageId = createEditorAgentClientMessageId(); const requestedProjectId = normalizedProjectId;
const requestId = pendingSendRequestIdRef.current + 1;
pendingSendRequestIdRef.current = requestId;
setErrorMessage(null); setErrorMessage(null);
setIsWaiting(true); setIsWaiting(true);
const optimisticMessage = createLocalUserMessage({ setPatienceNoticeConversationId(null);
id: -1, let conversationId: string | null = null;
clientMessageId, let optimisticMessage: EditorAgentMessage | null = null;
text,
attachments,
});
setMessages((currentMessages) => [
...currentMessages,
optimisticMessage,
]);
try { try {
conversationId = await ensureConversationForSend();
if (
pendingSendRequestIdRef.current !== requestId ||
normalizedProjectIdRef.current !== requestedProjectId
) {
return;
}
const clientMessageId = createEditorAgentClientMessageId();
const nextOptimisticMessage = createLocalUserMessage({
id: -1,
clientMessageId,
text,
attachments,
});
optimisticMessage = nextOptimisticMessage;
setMessages((currentMessages) => [
...currentMessages,
nextOptimisticMessage,
]);
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.sendMessage(
conversationId, conversationId,
{ {
@@ -396,6 +462,9 @@ export function useEditorAgentConversation({
{}, {},
); );
if (pendingSendRequestIdRef.current !== requestId) {
return;
}
setConversations((currentConversations) => setConversations((currentConversations) =>
upsertConversationSummary( upsertConversationSummary(
currentConversations, currentConversations,
@@ -413,15 +482,31 @@ export function useEditorAgentConversation({
} catch (error) { } catch (error) {
const message = const message =
error instanceof Error ? error.message : '发送画布 Agent 消息失败'; error instanceof Error ? error.message : '发送画布 Agent 消息失败';
if (activeConversationIdRef.current === conversationId) { const shouldReportError =
pendingSendRequestIdRef.current === requestId &&
(!conversationId ||
activeConversationIdRef.current === conversationId);
if (shouldReportError) {
setErrorMessage(message); setErrorMessage(message);
setMessages((currentMessages) => if (optimisticMessage) {
currentMessages.filter((message) => message !== optimisticMessage), setMessages((currentMessages) =>
); currentMessages.filter(
(message) => message !== optimisticMessage,
),
);
}
throw error;
} }
throw error;
} finally { } finally {
setIsWaiting(false); if (pendingSendRequestIdRef.current === requestId) {
if (patienceNoticeTimerRef.current !== null) {
clearTimeout(patienceNoticeTimerRef.current);
patienceNoticeTimerRef.current = null;
}
isWaitingRef.current = false;
setIsWaiting(false);
setPatienceNoticeConversationId(null);
}
} }
}, },
[ [
@@ -430,7 +515,7 @@ export function useEditorAgentConversation({
applyDeltaMessages, applyDeltaMessages,
isLoadingConversations, isLoadingConversations,
isLoadingMessages, isLoadingMessages,
isWaiting, normalizedProjectId,
], ],
); );
@@ -537,6 +622,8 @@ export function useEditorAgentConversation({
isCreatingConversation, isCreatingConversation,
isDeletingConversation, isDeletingConversation,
isWaiting, isWaiting,
isPatienceNoticeVisible:
isWaiting && patienceNoticeConversationId === activeConversationId,
toolCallAction, toolCallAction,
isToolCallActionPending: toolCallAction !== null, isToolCallActionPending: toolCallAction !== null,
errorMessage, errorMessage,