合并 web master 最新更新
将 web/master 的画布快照、图集标签和 Agent 框架更新合入像素艺术功能分支。 # Conflicts: # docs/project-memory/shared-memory/decision-log.md # server-rs/crates/api-server/src/external_editor_api.rs
This commit is contained in:
@@ -36,7 +36,7 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
|
||||
| Intent | Method and path | Required fields |
|
||||
| --- | --- | --- |
|
||||
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
|
||||
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
|
||||
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
|
||||
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
|
||||
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
|
||||
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
|
||||
|
||||
@@ -53,7 +53,7 @@ Ask a follow-up only when two routes could both be correct and produce different
|
||||
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
|
||||
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
|
||||
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
|
||||
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
|
||||
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
|
||||
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
|
||||
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
|
||||
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
|
||||
|
||||
@@ -270,11 +270,21 @@ class GenarrativeExternalClient:
|
||||
fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材"
|
||||
return fields
|
||||
|
||||
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
|
||||
def save_canvas(
|
||||
self,
|
||||
project_id: str,
|
||||
viewport: dict[str, Any],
|
||||
layers: dict[str, Any],
|
||||
expected_revision: int,
|
||||
) -> Any:
|
||||
return self.request_json(
|
||||
"PATCH",
|
||||
f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas",
|
||||
{"viewport": viewport, "layers": layers},
|
||||
{
|
||||
"viewport": viewport,
|
||||
"layers": layers,
|
||||
"expectedRevision": expected_revision,
|
||||
},
|
||||
)
|
||||
|
||||
def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str:
|
||||
|
||||
@@ -1761,7 +1761,8 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"viewport",
|
||||
"layers"
|
||||
"layers",
|
||||
"expectedRevision"
|
||||
],
|
||||
"properties": {
|
||||
"viewport": {
|
||||
@@ -1778,7 +1779,7 @@
|
||||
"expectedRevision": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "可选的画布 revision CAS;不匹配时返回 409。"
|
||||
"description": "必填的画布 revision CAS;不匹配时返回 409。"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -4260,8 +4260,8 @@
|
||||
|
||||
- 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`。
|
||||
- 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`。未单独配置 `GENARRATIVE_LLM_API_KEY` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。
|
||||
- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。
|
||||
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
|
||||
- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit-image` 并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得生成工具 schema 中不存在的 `source_image_id`,也不能因为本轮没有手动附件而退回 `generate-image`。
|
||||
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate-image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate-image`,只有实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`。
|
||||
- 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。
|
||||
- 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
@@ -4531,6 +4531,14 @@
|
||||
- 持久化边界:逻辑低分辨率图、像素化前后对比图、预览、诊断和报告一律不持久化;像素模式只替换原本即将上传的最终图片字节。普通图片、角色、图标的 OSS PUT、asset / project resource 和画布 item 数量必须与 `None` 模式完全一致;角色 / 图标最多因复用失败增加一次对已有 provider 对象的 OSS GET,不得增加 PUT、资源类型、画布项、队列类型或 schema 字段。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md`、`docs/openapi/genarrative-external-v1.openapi.json`。
|
||||
|
||||
## 2026-07-28 画布 Agent 的通用 function-calling harness 与画布 prompt 分层
|
||||
|
||||
- 背景:画布 Agent 的 JSON 输出协议、tool schema 注入、memory / hook、轮次保护和“全部工具待确认即结束回合”原先位于 `platform-editor-agent/src/framework`,与规范展板、已有图编辑路由、模型超时和画布工具混在同一 crate;八类工具还重复携带待确认控制话术。旧 `platform-agent` 已随 Creative Agent 退役,不能作为新公共层复活。
|
||||
- 决策:新增无旧玩法依赖的现役 `platform-agent-harness`,只承载业务中立的 function-calling 执行协议;`platform-editor-agent` 通过兼容 re-export 复用该 crate,并继续承载画布 LLM profile、角色 prompt、公共美术工具路由策略、图片上下文和工具实现。无工具场景同样注入 JSON 响应格式;prompt 不再宣称工具并发执行;request 级 system prompt 必须真实进入本轮请求。画布对话额外注入最近一条已完成图片的有界 `latestGeneratedImage` 元数据,后续编辑仍只用 SHA-256 `imageId` 选图。
|
||||
- 执行与失败决策:prompt 每轮通过 `AgentMemory::begin_staged` 使用与调用方 memory 行为等价、写入隔离的 `StagedAgentMemory` 事务;成功或已有工具活动时显式 `commit()`,直接 drop 表示回滚。无工具活动失败时回滚本轮 staged 增量,已发生工具活动后失败时提交已发生工具事实并追加 terminal error closure。外部 future drop / abort 若发生在工具完成后,提交工具结果与取消闭环;若发生在工具执行中,提交“已启动、结果未知”与取消闭环,后续先 reconcile,不能假装副作用未发生。harness 通过 `PromptRunError { error, partial_outputs }` 显式返回终态错误和失败前输出;结构化工具失败还必须向调用方保留 `ToolFailure.kind/retryable/fatal` 与原始 `output`,不在 harness 内压成单一字符串。api-server 的 18 分钟总 deadline 以 runtime future 下沉到 runner:completion 可被 deadline 终止,工具在开始前检查、开始后等待返回、返回后携带结果收口;禁止外层 timeout drop prompt 或中途取消 effectful tool 后伪造空 partial。
|
||||
- 保留边界:会话幂等、OSS 消息、120 秒前端软提示、20 分钟 transport、18 分钟 handler 总 deadline、1024 tokens、8 分钟 provider attempt、泥点计费、确认入队和 external job 懒回填均不进入公共 harness。SpacetimeDB schema、前端 wire DTO 和侧边栏 UI 不变。
|
||||
- 验证方式:`cargo test -p platform-agent-harness`、`cargo test -p platform-editor-agent`、`cargo test -p api-server editor_agent`、`cargo check -p api-server --locked`、DDD 边界检查、Rustfmt、编码检查和 `git diff --check`。
|
||||
|
||||
## 2026-07-29 图标图集拆分数量只由有效连通域决定
|
||||
|
||||
- 背景:图标素材生成前端曾把单个提示词按换行、逗号、顿号等分隔符解析成描述数组,后端再用数组长度作为期望切片数。这会把“各种敌人头像:骷髅 哥布林 强盗 龙 蝙蝠等”一类自然语言错误地解释为固定数量,并在图集中存在更多有效素材时截断结果。
|
||||
|
||||
@@ -349,7 +349,7 @@
|
||||
|
||||
- 现象:`/api/editor/projects*`、素材库、项目资源或 layout payload 里出现数 MB 的 `data:image/*`、`data:video/*`、`data:audio/*`,刷新恢复变慢,发布入口可能 OOM / 413,素材库缩略图还可能只显示文件名。
|
||||
- 原因:生成、规范图、角色图、图标 / UI spritesheet、音视频或动画帧如果直接把 Data URL / signed URL 写入 `editor_project_resource`、`editor_asset` 或 `editor_canvas.layers_json`,就把媒体本体塞进了项目快照;signed URL 还会过期,素材库也无法稳定换签。
|
||||
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"`、`objectKey`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,也不能在后端快照回来前自动保存。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
|
||||
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"`、`objectKey`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,必须按用户隔离,而且不能在后端快照回来前自动保存。认证状态变化重跑加载 effect 时,要同步用 ref 关闭写门禁并清除 revision、pending save 和 timer;不能只等 `isProjectReady=false` 的下一次 render,否则旧 effect 会先消费 skip 标记,再把公司浏览器的旧缓存无版本 PATCH 到服务端,覆盖另一台设备的新画布布局。现役 Web 与 External layout PATCH 的 `expectedRevision` 都必填,三层门禁分别放在 autosave effect、queue 和真正发送前;session cache 即使带 revision 也只有显示权。异步 project resource 创建必须把未发请求队列按用户 / 项目隔离,并记录发起时已接受的权威快照序号;若资源响应前发生认证重载、409 恢复或生成完成快照替换,只把新资源对应图层合并进当前权威布局,禁止用历史 `snapshotLayers` 整体覆盖。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
|
||||
- 验证:Network 中 `/api/editor/projects*`、`PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`。
|
||||
- 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/common/PlatformMediaFrame.tsx`、`src/services/assetReadUrlService.ts`。
|
||||
|
||||
@@ -3272,8 +3272,17 @@
|
||||
- 现象:画布 Agent 已生成有效工具规划,却最终只保存 `ERROR max turns reached: 3`,助手文本和待确认工具卡都消失。
|
||||
- 原因:八类画布工具的 `call()` 只返回待用户确认的规划结果,但 function-calling runner 在成功工具后仍继续请求 LLM,只靠 prompt 要求模型不再重试;模型连续返回工具调用直到上限后,错误结果又丢弃此前累积的输出。
|
||||
- 处理:工具通过框架契约显式声明 `requires_user_confirmation`;当本批全部工具都成功且等待确认时,runner 在处理完整批次后立即返回已有助手文本和工具结果。未知工具、参数错误、hook skip、普通连续工具和不可解析响应仍继续受 `max_turns` 门禁保护。不要用单纯提高轮次上限掩盖终止条件缺失。
|
||||
- 验证: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/`。
|
||||
- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”“多工具按数组顺序执行”“request 级 system prompt 真实进入请求”;公共 prompt 在无工具时仍必须包含 runner 所需的 JSON 响应格式,且不得宣称并发执行。
|
||||
- 关联:`server-rs/crates/platform-agent-harness/src/run.rs`、`server-rs/crates/platform-agent-harness/src/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/tools/`。
|
||||
|
||||
## Agent 终态失败不能吞掉已发生的工具事实
|
||||
|
||||
- 现象:同一轮 prompt 中前面工具已经成功生成待确认结果,但后续工具、hook、completion 或 `max_turns` 失败后,API 只保存最后一条 `ERROR `,已执行工具和用户本轮语义从会话历史中消失。
|
||||
- 原因:runner 只返回单一 `PromptError`,或者直接向 committed memory 逐步写入,无法区分“尚未发生外部工具事实,整轮可回滚”与“已发生工具事实,只能提交并闭合错误”。工具失败若被压成字符串,调用方还会丢失 `kind`、`retryable`、`fatal` 和原始 `output`。
|
||||
- 处理:用 `PromptRunError { error, partial_outputs }` 保留失败前输出,并将本轮 memory 先写入 staged buffer。无工具活动失败时整体回滚 staged 增量;有成功或失败工具活动时提交已发生事实,并追加 terminal error closure。api-server 按 `partial_outputs` 顺序先持久化成功工具的 `not_completed` 待确认消息,再追加 `ERROR ` 终态消息;`ToolFailed` 保留给调用方做诊断和流程决策,不伪装成成功确认卡。
|
||||
- 取消边界:不能在 prompt future 内对 `agent.memory.take()` 后跨 await 持有,也不能用统一 `VecMemory` staging 绕过自定义 memory 的限长、摘要或脱敏规则。`AgentMemory::begin_staged` 必须产生行为等价、写入隔离的 `StagedAgentMemory`,成功或已有工具活动时显式 `commit()`,直接 drop 才表示回滚。外部 drop 若发生在工具完成后,guard 必须提交结果与取消闭环;若工具仍在执行,至少提交“已启动、结果未知”事实,供后续 reconcile。正式总 deadline 应作为 runner 内部 future 终止 completion;工具开始前检查 deadline,一旦开始则不能中途 drop,必须等待结果后再携带 partial outputs 收口。外层 timeout 只适合作为进程级最后保险,不能承担业务收口。
|
||||
- 验证:至少覆盖“无工具 completion 失败回滚 staged 用户消息”“非 fatal 工具失败对调用方暴露 `kind/retryable/fatal/output`”“成功工具后终态失败保留 partial tool output”“有工具活动时 committed memory 末尾存在 error closure”以及“API 增量中待确认工具位于 terminal `ERROR ` 之前”。上一张图继续编辑的 prompt 测试必须断言 `latestGeneratedImage.imageId -> edit-image.object_image_id`,且最终 prompt 不含 `source_image_id`。
|
||||
- 关联:`server-rs/crates/platform-agent-harness/src/run.rs`、`server-rs/crates/platform-agent-harness/src/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/prompt.rs`、`server-rs/crates/api-server/src/editor_agent/api.rs`。
|
||||
|
||||
## 画布 Agent 的规划请求不能关闭瞬时失败重试
|
||||
|
||||
@@ -3281,7 +3290,7 @@
|
||||
- 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `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 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。
|
||||
- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 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`。
|
||||
- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-agent-harness/src/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 隔离
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
|
||||
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
|
||||
- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。
|
||||
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。
|
||||
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本,仍必须以后续显式读取或生成完成返回的后端快照作为项目真相。
|
||||
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
|
||||
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
|
||||
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
|
||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||
- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。
|
||||
- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存 key 和 envelope 必须按当前用户隔离并携带 revision,缓存快照必须排除 `data:*` / `blob:` 内联媒体。session 缓存只提供显示,不授予写权限;项目加载或认证身份变化触发重载时,必须先通过同步 ref 关闭写门禁、清除 revision 与待保存 timer,只有本次服务端权威快照携带 revision 并应用完成后才重新开放保存。不能只依赖异步 `isProjectReady` state 阻止同一轮 effect,也不能把缓存中的 revision 当作权威写凭据。未创建的 project resource 队列必须绑定发起用户和目标项目;已发出的 resource 请求还要捕获发起时的权威快照序号,若响应前画布已被更新权威快照替换,只把本次新资源对应的单个图层合并到当前布局后用最新 revision 保存,不得恢复请求发起前的整份旧图层数组,也不得把一个用户的 pending 图层排入另一个用户项目。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。
|
||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||
|
||||
## 后端接口
|
||||
@@ -88,7 +88,7 @@
|
||||
- `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。
|
||||
- `POST /api/editor/projects`:创建图片画布工程。
|
||||
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
|
||||
- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, updatedAt }` ack,不返回完整工程快照。
|
||||
- `PATCH /api/editor/projects/{projectId}`:携带必填 `expectedRevision` 保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, revision, updatedAt }` ack,不返回完整工程快照。
|
||||
- `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。
|
||||
- `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。
|
||||
- `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
- SpacetimeDB schema guard 比较当前工作树与基线提交时,两侧都必须分别读取各自 `Cargo.toml` 的 `lib.path`,再沿 `mod` / `#[path]` 只扫描该快照 crate root 可达的 schema;不得递归扫描整个 `src/`,否则原位保留的旧源码会与现役历史数据壳产生假 accessor 重复。
|
||||
- `module-runtime` 仍是账号、钱包、公共设置、追踪和 feature gate 的现役领域 crate;其混合源码中的 `CreationEntry*`、旧公开作品、旧存档 / 浏览历史 / 游玩统计 DTO、command、mapper 和规则必须以编译条件退出,且不再依赖只为旧创作契约存在的 `shared-contracts`。历史 schema 只继续编译 `RuntimeBrowseHistoryThemeMode` 六个变体和完整保序的 `RuntimeProfileWalletLedgerSourceType` 等持久化 ABI,不保留围绕这些类型的旧业务实现。
|
||||
- 纯模板 crate 和专属运行态 crate 不属于 workspace members、default members 或任何在运 crate 的依赖图;源码目录保持原样。
|
||||
- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。
|
||||
- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。后续抽出的 `platform-agent-harness` 是无旧玩法依赖的通用 JSON function-calling 底座,不得依赖、复用或重新挂回本条退役 crate。
|
||||
- `platform-auth` 不再编译 runtime guest token;`platform-wechat` 不再编译旧生成结果订阅服务,只保留现役认证和支付协议。
|
||||
|
||||
## 验收
|
||||
|
||||
@@ -24,7 +24,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk`
|
||||
|
||||
- HTTP 与运维入口:`api-server`、`pingora-gateway`、`server-manager-panel`。
|
||||
- 现役领域模块:`module-ai`、`module-assets`、`module-auth`、`module-editor-agent`、`module-runtime`。`module-runtime` 继续承载账号、钱包、公共设置、追踪、功能门禁等现役平台领域能力;该 crate 名称不代表旧玩法 runtime 路由仍在运行。
|
||||
- 平台副作用:`platform-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。
|
||||
- 平台副作用:`platform-agent-harness`、`platform-editor-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。已退役 Creative Agent 的旧 `platform-agent` 只保留历史源码,不属于现役 workspace 或依赖图。
|
||||
- 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。
|
||||
- SpacetimeDB:`spacetime-client`、`spacetime-module`。
|
||||
- 测试支撑:`tests-support`。
|
||||
@@ -76,11 +76,16 @@ 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` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
|
||||
- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。
|
||||
- `platform-agent-harness` 只承载与具体业务无关的 JSON function-calling 协议、工具 schema 注入、memory、hook、typed tool、轮次保护和待用户确认终止语义;`platform-editor-agent` 在其上叠加画布专属 LLM profile、system prompt、跨工具路由规则、图片上下文与八类生成工具。公共 harness 不依赖画布 DTO、计费、OSS、Axum 或 external job,也不得复用已退役的旧 `platform-agent`。harness 失败契约固定为 `PromptRunError { error, partial_outputs }`;`partial_outputs` 显式携带失败前已产生的助手文本、成功工具和结构化失败工具输出。`ToolFailure` 的 `kind`、`retryable`、`fatal` 及工具原始 `output` 必须对 harness 调用方可见,不得在公共层压成字符串或擅自丢弃。
|
||||
- prompt runner 对每个调用通过 `AgentMemory::begin_staged` 创建行为等价、写入隔离的 `StagedAgentMemory` 事务:限长、摘要、脱敏或持久化 memory 的 append 语义必须在本轮模型请求前生效,不得统一降级成 `VecMemory`。成功结束或已发生工具活动时必须显式调用 staged `commit()`,直接 drop 表示回滚。无工具活动的 completion、hook、解析或 `max_turns` 失败丢弃 staged transaction;已有成功或失败工具活动时在末尾追加 terminal error closure 后提交。外部 future drop / abort 若发生在工具完成后,必须提交工具结果与取消闭环;若工具仍在执行,则提交“已启动、结果未知”事实与取消闭环,供后续 reconcile,不能假装工具没有发生。
|
||||
- 画布 handler 的 18 分钟总 deadline 通过 runtime 提供的 deadline future 下沉到公共 runner:completion await 可被 deadline 终止;effectful tool 在开始前检查 deadline,开始后不被中途 drop,返回后再携带结果收口为 `PromptRunError`。禁止用外层 timeout 直接 drop 整个 prompt future 并伪造空 `partial_outputs`。
|
||||
- `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 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。
|
||||
- 画布 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 或生成工具使用。
|
||||
- planning prompt 注入的 `latestGeneratedImage.imageId` 只能映射到 `edit-image.object_image_id`;`source_image_id` 不是现役 `edit-image` schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。
|
||||
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
|
||||
- api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。
|
||||
- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
|
||||
- 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。
|
||||
- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、结构化画布表或 `external_generation_job` 的业务真相。未激活结构化存储的 canvas 才继续以 `editor_canvas.layers_json` 作为 legacy 布局真相。
|
||||
|
||||
@@ -45,7 +45,7 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP
|
||||
|
||||
所有用户布局写入必须携带读取快照时获得的 `expectedRevision`。procedure 在事务中校验 canvas 当前 revision;不一致返回 `409`,前端应重载后端最新快照,不能只换上新 revision 就原样重放冲突前的整包布局。
|
||||
|
||||
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后权威快照暂时加载失败时保留 pending save 并定时重新进入冲突恢复,不能等待用户再次拖动画布才恢复保存。
|
||||
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后冲突布局立即作废,权威快照暂时加载失败时保留冲突恢复状态并定时只重试 GET,不能把旧布局换上新 revision 后重放,也不能等待用户再次拖动画布才恢复。
|
||||
|
||||
本次结构化 V1 先保留旧 `{ viewport, layers }` PATCH 作为兼容输入。legacy canvas 即使携带 `expectedRevision` 也只做 CAS legacy 保存,不允许用户写入绕过 migration operator 直接激活 structured;只有已完成 backfill / activate、且 active 迁移记录的 revision / hash / 数量 / 资源引用校验均通过时,后端才在单个事务内把兼容输入拆成 layer / dialog 行并递增一次 revision。旧无 CAS procedure 不得写 structured canvas。V1 快照从 typed 列重组,`item_json / dialog_json` 只保留最大 512 KiB 的未结构化扩展字段。自包含本地图片序列在 active canvas 中只能继续保存已回填且 `layerId / resourceId / sourceType / item_json` 语义完全一致的原行;允许修改几何、层级、分组、显隐等 typed 布局字段。前端序列化按正常资源真相边界省略 `assetKind / generationInputs` 时,后端只从既有结构化行恢复这两个冻结字段再校验;显式修改仍拒绝。active 路径不再经过 legacy 元数据清洗,拒绝新增缺资源序列或改写既有帧、预览、prompt 和生成扩展。后续将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation;在此之前 2 MiB 仍是兼容整包入口的上限。
|
||||
|
||||
|
||||
@@ -106,12 +106,16 @@
|
||||
|
||||
- 编排复用 `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 仍在等待,不能伪装成已持久化失败。
|
||||
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
|
||||
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `imageId` / `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。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit-image`,并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得构造工具 schema 中不存在的 `source_image_id`。除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `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 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
|
||||
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
|
||||
- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试。
|
||||
- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind`、`retryable`、`fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
|
||||
- api-server 收到带 `partial_outputs` 的终态失败时,必须先按原顺序把其中已成功工具转成 `status=not_completed` 待确认消息并写入同一会话增量,再追加 `ERROR <错误内容>` 终态 system 消息;不得因后续轮次、其它工具或 `max_turns` 失败而吞掉已经执行并返回的工具结果。
|
||||
- 通用 JSON function-calling 协议、工具 schema 注入、memory / hook、`max_turns` 和“全部工具待确认即结束回合”统一由现役 `platform-agent-harness` 承载;无工具时也必须输出同一 JSON 响应格式。画布角色 prompt、规范展板 / 已有图路由、模型与超时 profile、八类工具、计费、OSS 会话和 external job 编排继续留在 `platform-editor-agent` / `api-server`,不得回流已退役的旧 `platform-agent`。
|
||||
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
|
||||
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
|
||||
|
||||
@@ -152,7 +156,7 @@
|
||||
- `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建);
|
||||
- `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删);
|
||||
- `POST /api/editor/agent-conversations/{conversationId}/messages`(JSON);
|
||||
- Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。
|
||||
- 通用 function-calling harness 放 `platform-agent-harness`;画布 Agent profile 与工具放 `platform-editor-agent`;会话、计费、OSS 和工具内部生成执行链路由 api-server 编排层承接,不复用 `creative_agent.rs` 内存会话。
|
||||
- `shared-contracts` + `packages/shared`:`editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation`、`deltaMessages` 和可选 `errorMessage`。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
|
||||
- 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
|
||||
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
|
||||
- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。
|
||||
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
|
||||
|
||||
## 面板结构
|
||||
@@ -96,4 +97,5 @@
|
||||
- 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。
|
||||
- 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的全部有效连通域图标图层,图标依次命名为 `素材 N`;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。
|
||||
- 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。
|
||||
- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。
|
||||
- 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。
|
||||
|
||||
Generated
+10
@@ -4018,6 +4018,15 @@ version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "platform-agent-harness"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platform-audio"
|
||||
version = "0.1.0"
|
||||
@@ -4056,6 +4065,7 @@ name = "platform-editor-agent"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"platform-agent-harness",
|
||||
"platform-audio",
|
||||
"platform-image",
|
||||
"platform-llm",
|
||||
|
||||
@@ -38,6 +38,7 @@ members = [
|
||||
"crates/platform-oss",
|
||||
"crates/platform-auth",
|
||||
"crates/platform-audio",
|
||||
"crates/platform-agent-harness",
|
||||
"crates/platform-hyper3d",
|
||||
"crates/platform-image",
|
||||
"crates/platform-llm",
|
||||
@@ -67,6 +68,7 @@ module-assets = { path = "crates/module-assets", default-features = false }
|
||||
module-auth = { path = "crates/module-auth", default-features = false }
|
||||
module-editor-agent = { path = "crates/module-editor-agent", default-features = false }
|
||||
module-runtime = { path = "crates/module-runtime", default-features = false }
|
||||
platform-agent-harness = { path = "crates/platform-agent-harness", default-features = false }
|
||||
platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false }
|
||||
platform-auth = { path = "crates/platform-auth", default-features = false }
|
||||
platform-audio = { path = "crates/platform-audio", default-features = false }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::future::IntoFuture;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
@@ -10,7 +9,9 @@ use module_editor_agent::{
|
||||
use platform_editor_agent::framework::agent_builder::AgentBuilder;
|
||||
use platform_editor_agent::framework::error::PromptError;
|
||||
use platform_editor_agent::framework::memory::VecMemory;
|
||||
use platform_editor_agent::framework::run::{PromptOutput, format_tool_call_message};
|
||||
use platform_editor_agent::framework::run::{
|
||||
PromptOutput, PromptRunError, format_tool_call_message,
|
||||
};
|
||||
use platform_llm::LlmMessage;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -47,6 +48,7 @@ use crate::http_error::AppError;
|
||||
use crate::request_context::RequestContext;
|
||||
use crate::state::AppState;
|
||||
use platform_editor_agent::agent::agent::LlmChatAgentBuilder;
|
||||
use platform_editor_agent::agent::prompt::{build_prompt_memory, editor_agent_system_prompt};
|
||||
use platform_editor_agent::agent::tools::context::EditorToolContext;
|
||||
use platform_editor_agent::agent::tools::edit_image::EditImageTool;
|
||||
use platform_editor_agent::agent::tools::generate_background_music::GenerateBackgroundMusicTool;
|
||||
@@ -57,7 +59,7 @@ use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEff
|
||||
use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool;
|
||||
use platform_editor_agent::agent::tools::generate_video::GenerateVideoTool;
|
||||
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
|
||||
use tokio::time::{Instant, timeout};
|
||||
use tokio::time::Instant;
|
||||
|
||||
const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
|
||||
const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000;
|
||||
@@ -190,18 +192,9 @@ pub async fn editor_agent_message(
|
||||
};
|
||||
|
||||
// The current user message is passed separately to prompt(), so memory stops before it.
|
||||
let previous_messages: Vec<LlmMessage> = document.messages[..history_end]
|
||||
.iter()
|
||||
.map(|message| match message.role {
|
||||
EditorAgentMessageRole::User => LlmMessage::user(&message.text),
|
||||
EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text),
|
||||
EditorAgentMessageRole::System => LlmMessage::system(&message.text),
|
||||
})
|
||||
// Tool calls and attachment bookkeeping are separate system messages.
|
||||
.rev()
|
||||
.take(18)
|
||||
.rev()
|
||||
.collect();
|
||||
// Tool calls and attachment bookkeeping are separate system messages. The prompt memory also
|
||||
// appends one bounded latestGeneratedImage context entry for natural-language image references.
|
||||
let previous_messages = build_prompt_memory(&document, history_end);
|
||||
|
||||
// Build tool context from document
|
||||
let tool_context = context::build_tool_context(&document);
|
||||
@@ -272,16 +265,19 @@ pub async fn editor_agent_message(
|
||||
|
||||
let remaining_prompt_duration =
|
||||
remaining_editor_agent_prompt_duration(message_started_at.elapsed());
|
||||
let agent_result = run_editor_agent_prompt_with_timeout(
|
||||
agent.prompt(LlmMessage::user(user_message.text.clone())),
|
||||
remaining_prompt_duration,
|
||||
)
|
||||
.await;
|
||||
let agent_result = agent
|
||||
.prompt(LlmMessage::user(user_message.text.clone()))
|
||||
.deadline(
|
||||
tokio::time::sleep(remaining_prompt_duration),
|
||||
editor_agent_prompt_deadline_error(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let assistant_now = now_rfc3339();
|
||||
|
||||
let (outputs, terminal_error) = split_prompt_result(agent_result);
|
||||
match build_delta_messages(
|
||||
agent_result,
|
||||
outputs,
|
||||
&assistant_now,
|
||||
document.messages.len(),
|
||||
&tool_context,
|
||||
@@ -293,11 +289,12 @@ pub async fn editor_agent_message(
|
||||
&conversation,
|
||||
&mut document,
|
||||
conversation_summary,
|
||||
error.to_string(),
|
||||
error.display_with_agent_label("美术 Agent").to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(delta_messages) => {
|
||||
Ok(mut delta_messages) => {
|
||||
append_terminal_error(&mut delta_messages, document.messages.len(), terminal_error);
|
||||
for msg in &delta_messages {
|
||||
document.messages.push(msg.clone());
|
||||
}
|
||||
@@ -316,20 +313,33 @@ fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration {
|
||||
Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed)
|
||||
}
|
||||
|
||||
async fn run_editor_agent_prompt_with_timeout<F>(
|
||||
future: F,
|
||||
duration: Duration,
|
||||
) -> Result<Vec<PromptOutput>, PromptError>
|
||||
where
|
||||
F: IntoFuture<Output = Result<Vec<PromptOutput>, PromptError>>,
|
||||
{
|
||||
timeout(duration, future.into_future())
|
||||
.await
|
||||
.unwrap_or_else(|_| {
|
||||
Err(PromptError::CompletionError(
|
||||
EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(),
|
||||
))
|
||||
})
|
||||
fn editor_agent_prompt_deadline_error() -> PromptError {
|
||||
PromptError::CompletionError(EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string())
|
||||
}
|
||||
|
||||
fn split_prompt_result(
|
||||
result: Result<Vec<PromptOutput>, PromptRunError>,
|
||||
) -> (Vec<PromptOutput>, Option<PromptError>) {
|
||||
match result {
|
||||
Ok(outputs) => (outputs, None),
|
||||
Err(error) => {
|
||||
let (terminal_error, partial_outputs) = error.into_parts();
|
||||
(partial_outputs, Some(terminal_error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn append_terminal_error(
|
||||
delta_messages: &mut Vec<EditorAgentMessage>,
|
||||
messages_offset: usize,
|
||||
terminal_error: Option<PromptError>,
|
||||
) {
|
||||
if let Some(error) = terminal_error {
|
||||
delta_messages.push(build_editor_agent_error_message(
|
||||
messages_offset + delta_messages.len(),
|
||||
error.display_with_agent_label("美术 Agent"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
fn build_editor_agent_error_message(
|
||||
@@ -564,14 +574,14 @@ mod tests {
|
||||
crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
|
||||
.expect("default editor pricing should load");
|
||||
let messages = build_delta_messages(
|
||||
Ok(vec![PromptOutput::Tool(ToolCallOutput {
|
||||
vec![PromptOutput::Tool(ToolCallOutput {
|
||||
tool_call: ToolCall {
|
||||
id: "tool-call-1".to_string(),
|
||||
name: tool_name.to_string(),
|
||||
args: json!({ "prompt": "轻快冒险音乐" }),
|
||||
},
|
||||
output: output.clone(),
|
||||
})]),
|
||||
})],
|
||||
"2026-07-23T00:00:00Z",
|
||||
0,
|
||||
&EditorToolContext::default(),
|
||||
@@ -598,7 +608,7 @@ mod tests {
|
||||
crate::editor_generation_config::load_editor_generation_pricing_from_paths(None)
|
||||
.expect("default editor pricing should load");
|
||||
let messages = build_delta_messages(
|
||||
Ok(vec![PromptOutput::Tool(ToolCallOutput {
|
||||
vec![PromptOutput::Tool(ToolCallOutput {
|
||||
tool_call: ToolCall {
|
||||
id: "tool-call-1".to_string(),
|
||||
name: GenerateVideoTool::NAME.to_string(),
|
||||
@@ -611,7 +621,7 @@ mod tests {
|
||||
}),
|
||||
},
|
||||
output: json!({ "message": "runner pending output" }),
|
||||
})]),
|
||||
})],
|
||||
"2026-07-23T00:00:00Z",
|
||||
0,
|
||||
&EditorToolContext::default(),
|
||||
@@ -642,15 +652,8 @@ mod tests {
|
||||
assert_eq!(display_value("sound"), Some("on"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn prompt_timeout_applies_to_the_whole_agent_run() {
|
||||
let error = run_editor_agent_prompt_with_timeout(
|
||||
std::future::pending::<Result<Vec<PromptOutput>, PromptError>>(),
|
||||
Duration::from_millis(1),
|
||||
)
|
||||
.await
|
||||
.expect_err("pending agent run should hit the prompt deadline");
|
||||
|
||||
#[test]
|
||||
fn prompt_deadline_applies_to_the_whole_agent_run() {
|
||||
assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000);
|
||||
assert_eq!(
|
||||
remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)),
|
||||
@@ -661,37 +664,64 @@ mod tests {
|
||||
Duration::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
editor_agent_prompt_deadline_error()
|
||||
.display_with_agent_label("美术 Agent")
|
||||
.to_string(),
|
||||
"美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn editor_agent_system_prompt() -> &'static str {
|
||||
r#"
|
||||
* image_id str format is like: sha256:*
|
||||
* when user referenced/uploaded image, a system message will notify you the image id(s).
|
||||
YOU MUST USE THESE IMAGE IDs(or more from former context) IN YOUR TOOL CALLS.(or why user upload them?)
|
||||
* to confirm a pending tool call, user should click a confirm button in their UI, instead of tell you "ok"/"confirm".
|
||||
If in that case, you should tip the user to use the confirm button, instead of repeat that pending tool call.
|
||||
* 用户所说的 规范图/参考图/生成的图/... 没有本质区别,all can be some image_id
|
||||
* 实际生成工具由后端按模型定价扣泥点, 不能承诺免费生成
|
||||
#[test]
|
||||
fn terminal_failure_keeps_partial_outputs_for_delta_persistence() {
|
||||
let result = Err(PromptRunError::new(
|
||||
PromptError::MaxTurnsReached { max_turns: 3 },
|
||||
vec![PromptOutput::Tool(ToolCallOutput {
|
||||
tool_call: ToolCall {
|
||||
id: "0".to_string(),
|
||||
name: GenerateImageTool::NAME.to_string(),
|
||||
args: json!({
|
||||
"prompt": "一座漂浮在云海上的城堡",
|
||||
"reference_image_ids": []
|
||||
}),
|
||||
},
|
||||
output: json!({ "message": "等待用户确认" }),
|
||||
})],
|
||||
));
|
||||
|
||||
你是 Genarrative 图片画布 Agent,负责帮助用户理解、规划和触发画布生成工具. 对话回复要简短.
|
||||
"#
|
||||
}
|
||||
let (outputs, terminal_error) = split_prompt_result(result);
|
||||
let mut delta_messages = build_delta_messages(
|
||||
outputs,
|
||||
"2026-07-28T00:00:00Z",
|
||||
4,
|
||||
&EditorToolContext::default(),
|
||||
&EditorGenerationPricingConfig {
|
||||
models: Default::default(),
|
||||
},
|
||||
)
|
||||
.expect("successful partial tool output should still build a confirmation card");
|
||||
append_terminal_error(&mut delta_messages, 4, terminal_error);
|
||||
|
||||
assert_eq!(delta_messages.len(), 2);
|
||||
assert_eq!(delta_messages[0].id, 4);
|
||||
assert!(delta_messages[0].tool_call.is_some());
|
||||
assert_eq!(delta_messages[1].id, 5);
|
||||
assert_eq!(delta_messages[1].role, EditorAgentMessageRole::System);
|
||||
assert_eq!(
|
||||
delta_messages[1].text,
|
||||
"ERROR 美术 Agent 规划轮数已达上限:3"
|
||||
);
|
||||
}
|
||||
}
|
||||
fn build_delta_messages(
|
||||
result: Result<Vec<PromptOutput>, PromptError>,
|
||||
outputs: Vec<PromptOutput>,
|
||||
created_at: &str,
|
||||
messages_offset: usize,
|
||||
tool_context: &EditorToolContext,
|
||||
pricing: &EditorGenerationPricingConfig,
|
||||
) -> Result<Vec<EditorAgentMessage>, PromptError> {
|
||||
let outputs = result?;
|
||||
let mut messages = Vec::with_capacity(outputs.len());
|
||||
|
||||
for (i, out) in outputs.into_iter().enumerate() {
|
||||
let absolute_idx = messages_offset + i;
|
||||
for out in outputs {
|
||||
let absolute_idx = messages_offset + messages.len();
|
||||
match out {
|
||||
PromptOutput::Text(text) => {
|
||||
messages.push(EditorAgentMessage {
|
||||
@@ -741,6 +771,10 @@ fn build_delta_messages(
|
||||
created_at: created_at.to_string(),
|
||||
});
|
||||
}
|
||||
// Tool failures are retained by the shared harness for callers that need structured
|
||||
// retry/diagnostic policy. The editor surface must not render them as confirmation
|
||||
// cards; a terminal failure is appended below as the existing ERROR system message.
|
||||
PromptOutput::ToolFailed(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,14 +41,14 @@ use spacetime_client::{
|
||||
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
|
||||
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
|
||||
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
|
||||
EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord,
|
||||
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
|
||||
EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord,
|
||||
EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput,
|
||||
EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord,
|
||||
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
|
||||
EditorShowcaseCampaignConfigRecord, ExternalGenerationJobPhaseUpdateError,
|
||||
ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError,
|
||||
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
|
||||
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
|
||||
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
|
||||
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
|
||||
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
|
||||
ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput,
|
||||
SpacetimeClientError,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -166,7 +166,7 @@ pub struct EditorCanvasViewportPayload {
|
||||
pub struct EditorProjectLayoutSaveRequest {
|
||||
pub(crate) viewport: EditorCanvasViewportPayload,
|
||||
pub(crate) layers: Value,
|
||||
pub(crate) expected_revision: Option<u64>,
|
||||
pub(crate) expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -459,8 +459,7 @@ pub struct EditorProjectResponse {
|
||||
pub struct EditorProjectLayoutSaveResponse {
|
||||
project_id: String,
|
||||
canvas_id: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
revision: Option<u64>,
|
||||
revision: u64,
|
||||
updated_at: String,
|
||||
}
|
||||
|
||||
@@ -1150,48 +1149,26 @@ pub async fn save_editor_project_layout(
|
||||
let layers_json = serialize_editor_layers(payload.layers)?;
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let updated_at_micros = current_utc_micros();
|
||||
let (project_id, canvas_id, revision, updated_at) =
|
||||
if let Some(expected_revision) = expected_revision {
|
||||
let ack = state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput {
|
||||
project_id,
|
||||
owner_user_id,
|
||||
viewport,
|
||||
layers_json,
|
||||
expected_revision,
|
||||
updated_at_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
(
|
||||
ack.project_id,
|
||||
ack.canvas_id,
|
||||
Some(ack.revision),
|
||||
ack.updated_at,
|
||||
)
|
||||
} else {
|
||||
let ack = state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput {
|
||||
project_id,
|
||||
owner_user_id,
|
||||
viewport,
|
||||
layers_json,
|
||||
updated_at_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
(ack.project_id, ack.canvas_id, None, ack.updated_at)
|
||||
};
|
||||
let ack = state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput {
|
||||
project_id,
|
||||
owner_user_id,
|
||||
viewport,
|
||||
layers_json,
|
||||
expected_revision,
|
||||
updated_at_micros,
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
EditorProjectLayoutSaveResponse {
|
||||
project_id,
|
||||
canvas_id,
|
||||
revision,
|
||||
updated_at,
|
||||
project_id: ack.project_id,
|
||||
canvas_id: ack.canvas_id,
|
||||
revision: ack.revision,
|
||||
updated_at: ack.updated_at,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -8328,6 +8305,24 @@ mod tests {
|
||||
assert_eq!(error.status_code(), StatusCode::CONFLICT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_project_layout_save_request_requires_expected_revision() {
|
||||
let missing_revision = serde_json::from_value::<EditorProjectLayoutSaveRequest>(json!({
|
||||
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
|
||||
"layers": [],
|
||||
}))
|
||||
.expect_err("现役画布保存缺少 expectedRevision 时必须在进入写路径前失败");
|
||||
assert!(missing_revision.to_string().contains("expectedRevision"));
|
||||
|
||||
let request = serde_json::from_value::<EditorProjectLayoutSaveRequest>(json!({
|
||||
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
|
||||
"layers": [],
|
||||
"expectedRevision": 7,
|
||||
}))
|
||||
.expect("携带 expectedRevision 的画布保存请求应通过反序列化");
|
||||
assert_eq!(request.expected_revision, 7);
|
||||
}
|
||||
|
||||
fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision {
|
||||
EditorScreenBackgroundDecision {
|
||||
color: parse_editor_screen_background_color(Some(hex))
|
||||
|
||||
@@ -11,8 +11,8 @@ use spacetime_client::{
|
||||
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
|
||||
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
|
||||
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput,
|
||||
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
|
||||
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
|
||||
EditorProjectResourceCreateRecordInput,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
@@ -33,7 +33,7 @@ use crate::{
|
||||
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
|
||||
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
|
||||
parse_editor_generation_json_payload, save_editor_project_layout_with_revision_and_get,
|
||||
serialize_editor_asset_metadata, serialize_editor_layers,
|
||||
serialize_editor_asset_metadata,
|
||||
},
|
||||
external_api_auth::ExternalApiPrincipal,
|
||||
http_error::AppError,
|
||||
@@ -63,7 +63,7 @@ pub struct ExternalEditorProjectCreateRequest {
|
||||
pub struct ExternalEditorCanvasSaveRequest {
|
||||
viewport: EditorCanvasViewportPayload,
|
||||
layers: Value,
|
||||
expected_revision: Option<u64>,
|
||||
expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -341,29 +341,15 @@ pub async fn save_external_editor_canvas(
|
||||
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
|
||||
let project = if let Some(expected_revision) = payload.expected_revision {
|
||||
save_editor_project_layout_with_revision_and_get(
|
||||
&state,
|
||||
project_id.as_str(),
|
||||
principal.owner_user_id(),
|
||||
payload.viewport.into_record(),
|
||||
payload.layers,
|
||||
expected_revision,
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
state
|
||||
.spacetime_client()
|
||||
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
|
||||
project_id,
|
||||
owner_user_id: principal.owner_user_id().to_string(),
|
||||
viewport: payload.viewport.into_record(),
|
||||
layers_json: serialize_editor_layers(payload.layers)?,
|
||||
updated_at_micros: current_utc_micros(),
|
||||
})
|
||||
.await
|
||||
.map_err(map_editor_project_error)?
|
||||
};
|
||||
let project = save_editor_project_layout_with_revision_and_get(
|
||||
&state,
|
||||
project_id.as_str(),
|
||||
principal.owner_user_id(),
|
||||
payload.viewport.into_record(),
|
||||
payload.layers,
|
||||
payload.expected_revision,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
@@ -805,6 +791,24 @@ fn normalize_project_title(title: Option<String>) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn external_editor_canvas_save_request_requires_expected_revision() {
|
||||
let missing_revision = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
|
||||
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
|
||||
"layers": [],
|
||||
}))
|
||||
.expect_err("外部画布保存缺少 expectedRevision 时必须在进入写路径前失败");
|
||||
assert!(missing_revision.to_string().contains("expectedRevision"));
|
||||
|
||||
let request = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
|
||||
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
|
||||
"layers": [],
|
||||
"expectedRevision": 7,
|
||||
}))
|
||||
.expect("外部画布保存携带 expectedRevision 时应通过反序列化");
|
||||
assert_eq!(request.expected_revision, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exported_openapi_json_contains_external_editor_routes_and_security() {
|
||||
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
|
||||
@@ -888,6 +892,11 @@ mod tests {
|
||||
parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"],
|
||||
"array"
|
||||
);
|
||||
assert!(
|
||||
parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"]
|
||||
.as_array()
|
||||
.is_some_and(|required| required.contains(&json!("expectedRevision")))
|
||||
);
|
||||
assert!(
|
||||
parsed["paths"]
|
||||
.get("/api/external/v1/editor/images/edits")
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "platform-agent-harness"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt", "time"] }
|
||||
+9
-5
@@ -1,8 +1,8 @@
|
||||
use crate::framework::error::PromptError;
|
||||
use crate::framework::hook::Hook;
|
||||
use crate::framework::memory::AgentMemory;
|
||||
use crate::framework::run::PromptRequest;
|
||||
use crate::framework::tool::{Tool, ToolDyn};
|
||||
use crate::error::PromptError;
|
||||
use crate::hook::Hook;
|
||||
use crate::memory::AgentMemory;
|
||||
use crate::run::PromptRequest;
|
||||
use crate::tool::{Tool, ToolDyn};
|
||||
|
||||
pub struct Agent<M: LlmApiAdaptor<Message>, Message> {
|
||||
pub model: M,
|
||||
@@ -76,4 +76,8 @@ pub trait LlmApiAdaptor<Message>: Send + Sync {
|
||||
fn tool_result_message(&self, tool_name: &str, output: &str) -> Message;
|
||||
|
||||
fn build_assistant_message(&self, text: &str) -> Message;
|
||||
|
||||
fn build_error_message(&self, error: &PromptError) -> Message {
|
||||
self.tool_result_message("agent-error", &error.to_string())
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
use crate::framework::agent::{Agent, LlmApiAdaptor};
|
||||
use crate::framework::hook::Hook;
|
||||
use crate::framework::memory::AgentMemory;
|
||||
use crate::framework::tool::Tool;
|
||||
use crate::agent::{Agent, LlmApiAdaptor};
|
||||
use crate::hook::Hook;
|
||||
use crate::memory::AgentMemory;
|
||||
use crate::tool::Tool;
|
||||
|
||||
pub trait AgentBuilder<Message, M: LlmApiAdaptor<Message>> {
|
||||
type Client;
|
||||
@@ -0,0 +1,100 @@
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PromptError {
|
||||
CompletionError(String),
|
||||
ToolError(String),
|
||||
InternalError(String),
|
||||
MaxTurnsReached { max_turns: usize },
|
||||
}
|
||||
|
||||
impl PromptError {
|
||||
/// 使用业务入口自己的 Agent 名称展示错误,同时保持 harness 默认文案中性。
|
||||
pub fn display_with_agent_label<'a>(&'a self, agent_label: &'a str) -> PromptErrorDisplay<'a> {
|
||||
PromptErrorDisplay {
|
||||
error: self,
|
||||
agent_label,
|
||||
}
|
||||
}
|
||||
|
||||
fn fmt_with_agent_label(
|
||||
&self,
|
||||
formatter: &mut std::fmt::Formatter<'_>,
|
||||
agent_label: &str,
|
||||
) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::CompletionError(message) => {
|
||||
write!(formatter, "{agent_label} 规划失败:{message}")
|
||||
}
|
||||
Self::ToolError(message) => {
|
||||
write!(formatter, "{agent_label} 工具执行失败:{message}")
|
||||
}
|
||||
Self::InternalError(message) => {
|
||||
write!(formatter, "{agent_label} 内部错误:{message}")
|
||||
}
|
||||
Self::MaxTurnsReached { max_turns } => {
|
||||
write!(formatter, "{agent_label} 规划轮数已达上限:{max_turns}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PromptErrorDisplay<'a> {
|
||||
error: &'a PromptError,
|
||||
agent_label: &'a str,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PromptErrorDisplay<'_> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.error.fmt_with_agent_label(formatter, self.agent_label)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PromptError {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
self.fmt_with_agent_label(formatter, "Agent")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PromptError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_display_uses_neutral_agent_label() {
|
||||
let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string());
|
||||
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"Agent 规划失败:LLM 请求超时,累计尝试 2 次"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_can_supply_a_surface_specific_agent_label() {
|
||||
assert_eq!(
|
||||
PromptError::CompletionError("LLM 请求超时".to_string())
|
||||
.display_with_agent_label("美术 Agent")
|
||||
.to_string(),
|
||||
"美术 Agent 规划失败:LLM 请求超时"
|
||||
);
|
||||
assert_eq!(
|
||||
PromptError::ToolError("参数无效".to_string())
|
||||
.display_with_agent_label("美术 Agent")
|
||||
.to_string(),
|
||||
"美术 Agent 工具执行失败:参数无效"
|
||||
);
|
||||
assert_eq!(
|
||||
PromptError::InternalError("序列化失败".to_string())
|
||||
.display_with_agent_label("美术 Agent")
|
||||
.to_string(),
|
||||
"美术 Agent 内部错误:序列化失败"
|
||||
);
|
||||
assert_eq!(
|
||||
PromptError::MaxTurnsReached { max_turns: 3 }
|
||||
.display_with_agent_label("美术 Agent")
|
||||
.to_string(),
|
||||
"美术 Agent 规划轮数已达上限:3"
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
use crate::framework::run::{TextFlow, ToolCallFlow};
|
||||
use crate::framework::tool::ToolCall;
|
||||
use crate::run::{TextFlow, ToolCallFlow};
|
||||
use crate::tool::ToolCall;
|
||||
|
||||
pub trait Hook: Send + Sync {
|
||||
fn on_text_reply(&self, _text: &str) -> TextFlow {
|
||||
@@ -0,0 +1,10 @@
|
||||
//! 与具体业务 Agent、模型和界面无关的 function-calling harness。
|
||||
|
||||
pub mod agent;
|
||||
pub mod agent_builder;
|
||||
pub mod error;
|
||||
pub mod hook;
|
||||
pub mod memory;
|
||||
pub mod prompt;
|
||||
pub mod run;
|
||||
pub mod tool;
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Agent 对话记忆的最小公共抽象。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub trait AgentMemoryBuffer<Message>: Send + Sync {
|
||||
fn get_memory(&self) -> &[Message];
|
||||
fn append_message(&mut self, message: Message);
|
||||
}
|
||||
|
||||
pub trait AgentMemory<Message>: AgentMemoryBuffer<Message> {
|
||||
/// 创建与当前 memory 行为等价、但写入彼此隔离的本轮事务副本。
|
||||
fn begin_staged(&self) -> Box<dyn StagedAgentMemory<Message>>;
|
||||
}
|
||||
|
||||
pub trait StagedAgentMemory<Message>: AgentMemoryBuffer<Message> {
|
||||
/// 显式提交本轮事务,并返回下一轮继续使用的 committed memory。
|
||||
///
|
||||
/// 持久化实现应在这里发布 staged 写入;直接 drop staged transaction 表示回滚。
|
||||
fn commit(self: Box<Self>) -> Box<dyn AgentMemory<Message>>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VecMemory<Message> {
|
||||
messages: Vec<Message>,
|
||||
}
|
||||
|
||||
impl<Message> VecMemory<Message> {
|
||||
pub fn new(messages: Vec<Message>) -> Self {
|
||||
Self { messages }
|
||||
}
|
||||
|
||||
pub fn into_inner(self) -> Vec<Message> {
|
||||
self.messages
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Send + Sync> AgentMemoryBuffer<Message> for VecMemory<Message> {
|
||||
fn get_memory(&self) -> &[Message] {
|
||||
&self.messages
|
||||
}
|
||||
|
||||
fn append_message(&mut self, message: Message) {
|
||||
self.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone + Send + Sync + 'static> AgentMemory<Message> for VecMemory<Message> {
|
||||
fn begin_staged(&self) -> Box<dyn StagedAgentMemory<Message>> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<Message: Clone + Send + Sync + 'static> StagedAgentMemory<Message> for VecMemory<Message> {
|
||||
fn commit(self: Box<Self>) -> Box<dyn AgentMemory<Message>> {
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use serde_json::Value;
|
||||
|
||||
pub const PENDING_USER_CONFIRMATION_MESSAGE: &str =
|
||||
"tool call is pending user confirmation; end this turn when all tool calls are pending";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ToolPromptSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// 把业务 system prompt、统一 JSON function-calling 输出契约和工具 schema 组合起来。
|
||||
pub fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) -> String {
|
||||
let mut prompt = String::new();
|
||||
prompt.push_str(base_prompt);
|
||||
prompt.push_str("\n\nYou have access to the following tools.\n\n");
|
||||
prompt.push_str("## JSON Response Format\n");
|
||||
prompt.push_str("respond with valid JSON only (no markdown fences):\n");
|
||||
prompt.push_str("{\n");
|
||||
prompt.push_str(" \"reply_text\": \"your message to the user\",\n");
|
||||
prompt.push_str(" \"tool_calls\": [\n {\n");
|
||||
prompt.push_str(" \"tool_name\": \"tool_name_here\",\n");
|
||||
prompt.push_str(" \"args\": { \"argument_name\": \"argument_value\" }\n");
|
||||
prompt.push_str(" }\n ]\n");
|
||||
prompt.push_str("}\n\n");
|
||||
prompt.push_str("If you don't need to use a tool, respond with:\n");
|
||||
prompt.push_str("{\n");
|
||||
prompt.push_str(" \"reply_text\": \"your message\",\n");
|
||||
prompt.push_str(" \"tool_calls\": []\n");
|
||||
prompt.push_str("}\n\n");
|
||||
prompt.push_str("## Available Tools\n\n");
|
||||
|
||||
if tool_specs.is_empty() {
|
||||
prompt.push_str("(No tools available.)\n");
|
||||
} else {
|
||||
for tool in tool_specs {
|
||||
prompt.push_str(&format!("- {}\n", tool.name));
|
||||
prompt.push_str(&format!(" Description: {}\n", tool.description));
|
||||
prompt.push_str(" Arguments JSON Schema:\n");
|
||||
let parameters = serde_json::to_string_pretty(&tool.parameters)
|
||||
.unwrap_or_else(|_| tool.parameters.to_string());
|
||||
prompt.push_str(¶meters);
|
||||
prompt.push('\n');
|
||||
}
|
||||
|
||||
prompt.push_str("tool_calls can contain multiple calls in one turn. ");
|
||||
prompt.push_str("Calls are executed sequentially in array order. ");
|
||||
prompt.push_str("Valid tool calls are recorded as system messages. ");
|
||||
prompt.push_str(
|
||||
"Some tools require user confirmation. Do not retry the same tool call while it is pending. \
|
||||
If all tool calls are pending confirmation, end the turn and wait for the user's action.",
|
||||
);
|
||||
}
|
||||
|
||||
prompt
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_prompt_keeps_the_shared_json_and_confirmation_contract() {
|
||||
let prompt = build_tools_system_prompt(
|
||||
"业务提示词",
|
||||
&[ToolPromptSpec {
|
||||
name: "generate-image".to_string(),
|
||||
description: "生成图片".to_string(),
|
||||
parameters: json!({
|
||||
"type": "object",
|
||||
"properties": { "prompt": { "type": "string" } },
|
||||
"required": ["prompt"]
|
||||
}),
|
||||
}],
|
||||
);
|
||||
|
||||
assert!(prompt.starts_with("业务提示词"));
|
||||
assert!(prompt.contains("\"reply_text\""));
|
||||
assert!(prompt.contains("\"tool_calls\""));
|
||||
assert!(prompt.contains("- generate-image"));
|
||||
assert!(prompt.contains("\"required\": ["));
|
||||
assert!(prompt.contains("Do not retry the same tool call"));
|
||||
assert!(prompt.contains("all tool calls are pending confirmation"));
|
||||
assert!(prompt.contains("executed sequentially in array order"));
|
||||
assert!(!prompt.contains("concurrently"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_prompt_preserves_the_no_tools_shape() {
|
||||
let prompt = build_tools_system_prompt("基础提示词", &[]);
|
||||
|
||||
assert!(prompt.starts_with("基础提示词"));
|
||||
assert!(prompt.contains("## JSON Response Format"));
|
||||
assert!(prompt.contains("\"tool_calls\": []"));
|
||||
assert!(prompt.ends_with("## Available Tools\n\n(No tools available.)\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_confirmation_message_is_shared_control_flow_copy() {
|
||||
assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("pending user confirmation"));
|
||||
assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("end this turn"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user