From 05d96abd0ebef812bcf19f52ec39f178d4d9c321 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Fri, 6 Nov 2026 19:59:56 +0800 Subject: [PATCH] Integrate Canvas V2 subgraphs, editable edges and AI runs --- README.md | 71 ++--- VALIDATION.md | 190 ++----------- src/app/api/foundry/[...path]/route.ts | 39 ++- src/components/canvas/ai-panel.tsx | 51 ++++ src/components/canvas/canvas-edge.tsx | 129 +++++++-- src/components/canvas/canvas-text-editor.tsx | 2 +- src/components/canvas/canvas.module.css | 52 ++++ src/components/canvas/content-handles.tsx | 6 + src/components/canvas/draft-recovery.tsx | 6 +- src/components/canvas/edge-editing.tsx | 4 + src/components/canvas/edge-toolbar.tsx | 32 +++ src/components/canvas/frame-label-menu.tsx | 84 +----- src/components/canvas/frame-node.tsx | 26 +- src/components/canvas/graph-canvas.tsx | 236 ++++++++++++---- src/components/canvas/material-node.tsx | 3 +- src/components/canvas/node-style-toolbar.tsx | 3 +- src/components/canvas/plain-image-node.tsx | 3 +- src/components/canvas/transformable-node.tsx | 66 +++-- src/components/canvas/use-canvas-ai.ts | 94 +++++++ src/components/canvas/use-canvas-tree.ts | 271 +++++++++++++++++++ src/lib/api.ts | 24 +- src/lib/canvas-curves.ts | 35 +++ src/lib/canvas-elements.ts | 7 + src/lib/canvas-geometry.ts | 14 +- src/lib/canvas-loader.ts | 3 +- src/lib/canvas-subgraphs.ts | 61 +++++ src/lib/canvas-sync.ts | 17 +- src/lib/canvas-tree.ts | 94 +++++++ src/lib/canvas-v2.ts | 91 +++++++ src/lib/canvas.ts | 49 +++- src/lib/workspace.ts | 2 + tests/api-smoke.mjs | 29 +- tests/canvas-ui-check.mjs | 65 +++-- tests/canvas-v2-scenario.mjs | 135 +++++++++ tests/canvas-v2.test.mjs | 75 +++++ 35 files changed, 1626 insertions(+), 443 deletions(-) create mode 100644 src/components/canvas/ai-panel.tsx create mode 100644 src/components/canvas/content-handles.tsx create mode 100644 src/components/canvas/edge-editing.tsx create mode 100644 src/components/canvas/edge-toolbar.tsx create mode 100644 src/components/canvas/use-canvas-ai.ts create mode 100644 src/components/canvas/use-canvas-tree.ts create mode 100644 src/lib/canvas-curves.ts create mode 100644 src/lib/canvas-subgraphs.ts create mode 100644 src/lib/canvas-tree.ts create mode 100644 src/lib/canvas-v2.ts create mode 100644 tests/canvas-v2-scenario.mjs create mode 100644 tests/canvas-v2.test.mjs diff --git a/README.md b/README.md index 6f0eca7..63cb7cb 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ Google / Slack 的提供方回调仍是 Foundry 后端地址;后端的项目 - 本地演示提供示例图谱、搜索、新建和浏览器保存,支持图谱独立地址与刷新。 - 图谱页面使用 React Flow 12.12.1,支持空白处平移、节点拖动、连线、多选、缩放和文本编辑。文字、脚本、图片节点和创作模板可从底部悬浮工具栏插入。 - 右上角悬浮框提供撤销、重做、缩放、恢复 100%、适应画布和 JSON 导出;底部支持 JSON 导入及操作帮助。 -- 真实图谱先读取 Core Canvas 快照,再开放编辑;节点、样式、连线与视图以独立版本串行自动保存。旧本地草稿需用户明确选择恢复;版本冲突停止覆盖并保留恢复记录。演示模式仅本地保存。分区框及成员归属已匹配 Core 022086b 的保存契约,详见 docs/CORE_FRAME_EXTENSION.md。 +- 真实图谱先读取 Core Canvas 快照,再开放编辑;节点、样式、连线与视图以独立版本串行自动保存。旧本地草稿需用户明确选择恢复;版本冲突停止覆盖并保留恢复记录。演示模式仅本地保存。支持 Core Canvas V1/V2;V2 分区是真实子画布,内部内容按所属画布独立保存。 - 分享、Agent、收藏、自由绘图及多人协同尚未接入。画布覆盖整个视口,侧栏和快捷浮框覆盖其上;演示数据不写入线上。 - 收起侧栏后,左上角浮框显示 Logo 和用户头像;头像与展开侧栏底部的账户按钮打开同一个菜单。 - 本地演示仅用于查看界面,不提供后端认证与权限。 @@ -88,57 +88,36 @@ API 测试使用本机临时后端和独立前端进程,检查会话 Cookie、 ## 画布操作 -- 拖动空白处平移;拖动节点标题或边框移动节点。抓手模式下也可从节点内容处平移。 -- V 选择、H 抓手,按住空格临时平移,Shift 框选;拖动节点两侧的圆点创建连线。 -- 触控板滚动平移,Ctrl / ⌘ 加滚动或双指捏合缩放。右上角提供缩放和适应画布。 -- 底部可添加文字、图片、脚本或三节点创作模板。图片支持 PNG/JPEG/WebP/GIF,单张上限 2 MB。 -- Ctrl / ⌘ + Z 撤销,Shift + Ctrl / ⌘ + Z 重做;支持 JSON 导入导出和 40 步撤销历史。 -- 状态与导入回归:`node --experimental-strip-types --test tests/canvas.test.mjs tests/workspace.test.mjs`。 +- 单击选择,双击编辑文本或卡片名称。纯文本与图片可整块拖动;选中时四侧连接点可拖线。 +- 触控板滚动平移,Ctrl / ⌘ 加滚动或捏合缩放。V 选择,H 抓手,空格临时平移,Shift 框选。 +- 上方编辑栏提供文字样式、旋转和八向拉伸模式。旋转时 Shift 吸附 15°;八向拉伸时 Shift 固定比例,Esc 退出。图标保留等比缩放预设。 +- 连线可单独选择:颜色、粗细、透明度、实/虚/点线、箭头与箭尾形状/大小/填充均可设置。双击线或点击添加按钮插入曲线点,拖动锚点和控制柄制作多段贝塞尔链;支持平滑、镜像和独立控制柄。 +- 连线动画可关闭、始终播放或仅执行时播放,包含沿完整曲线的流光、流动虚线和脉冲;方向、颜色和时长可设。执行动画由真实任务边事件驱动。 +- 底部 AI 模式可保持开启;选择文本/图片/卡片后,上方为编辑栏,下方为固定屏幕尺寸的 AI 面板。模型从后端目录读取,支持原地生成、取消及可选下游传播;冲突结果保留供明确应用。 +- 新选框通过服务端事务创建并关联真实子画布。标签用三个图标切换隐藏/固定字号/随缩放,悬停查看解释。选框可收起、展开及调整概览比例,子图真实内容尺寸不变。 +- 展开内容使用子画布投影,编辑保存到实际 owner;不会在父图复制一份。拖入/移出使用多画布事务;新跨图连线按后端能力通过 connections 命令创建端口链。删除选框解散并保留内容。 +- 原地生成与用户拖动可合并;内容冲突保留本地草稿。网络结果不确定时暂停编辑,用相同幂等请求重试或重新读取。每个画布有独立版本与串行保存器。 +- 图标面板支持 2313 个 Lucide 和 24 个 Tabler 图标试用、分类/搜索、虚拟列表及本地图形分片加载。未打开面板不加载目录。 +- 有子图时导出完整 owner 文档集合;关系型子图备份不直接覆盖导入到其他画布。历史 V1/关系不兼容草稿可导出,不能覆盖 V2 子图关系。 -## 悬浮栏尺寸 +## Canvas V2 接口与检查 -左上角显示当前图谱标题,复用页面已读取的资料,不额外请求后端;长标题省略并可悬停查看。左上、右上、底部与帮助按钮共用 `toolbar.module.css`:图标/Logo/浮框头像 20px,图标按钮含 padding 的总尺寸为 32×32px,主文字 14px,容器内边距 4px,间隙 4px,圆角 8px。悬浮栏距屏幕边缘统一为 12px;右上角窄屏时下移避免与标题重叠。 +后端基线由用户交付的部署 `b4e537b`、CI #912/#913 与指定测试画布迁移摘要确认。跨图 connections 扩展以线上 `capabilities.subgraphs.connections` 为准;客户端先检查能力,再发事务。前端未重复运行迁移或调用线上付费模型。 -## 图谱改名 +同源代理转发 V2 Accept、If-Match、Idempotency-Key 和事件分页查询,保留 ETag/schema/Retry-After;受管图片通过带会话的二进制代理读取。快照上限 5 MiB,图片上传支持受限 multipart;服务令牌不会发给浏览器。 -左上角标题第一次点击选中,选中后再次点击进入编辑;回车或失焦保存,Esc 取消,F2 也可直接编辑。真实图谱改名先读取最新资料和版本,再携带 If-Match 更新名称,保留描述与内容;版本冲突时提示重试。演示模式保存到本地。保存后标题、侧栏列表及导出文件名同步更新。 +验证命令: -左上和底部工具栏按右上视图控件的分组节奏增加横向留白(间隙 6px、横向内边距 6px),按钮仍为 32×32px,竖向内边距仍为 4px。三处分隔线统一;左侧标题不再额外叠加外边距,右上现有样式保持不变。 +```sh +npm run typecheck +node --experimental-strip-types --test tests/*.test.mjs +CANVAS_UI_TOOLS=/path/to/isolated-tools node tests/canvas-ui-check.mjs +``` -卡片选中时,四边正中心淡入固定尺寸的小连接点;取消选中后淡出隐藏,不再显示大加号。拖动连接点建立贝塞尔连线,曲线直接连接两端卡片边缘中心,缩放及旋转后同步更新;选中后点击连接点打开菜单,选择文本、上传图片或脚本后再创建节点并连线。选中卡片拖线到空白处,也会在释放位置打开类型菜单。新增节点与连线组成一步撤销。 +DOM 工具仅需在临时目录安装 esbuild/jsdom,见测试文件开头;不启动浏览器。生产构建与 `tests/api-smoke.mjs` 使用隔离副本和临时本地服务,避免影响现有开发服务器。实际结果及限制见 VALIDATION.md。 -## 元素变换、样式与继承 +Headless Canvas API 的拆包方案保留在本地忽略的 `docs/HEADLESS_CANVAS_API_DRAFT.md`,当前没有创建或发布该库。其他已被替代的元素/分区草案已清理。 -- 右下角缩放符号始终显示,拖动会选中并调整大小,已移除八向缩放控件。样式浮栏中的旋转按钮开启旋转模式,拖动元素旋转,Shift 吸附 15°;点击外部或按 Esc 退出。纯文本宽、高独立调整,文字随宽度换行,字号由样式栏控制;图标保持等比缩放。 -- 卡片标题位于框外,整个框内是文本编辑区域;卡片与纯文本均取消独立的聚焦内框。 -- 样式浮栏提供文字颜色、字体、字号、加粗、斜体、下划线、对齐、行距、背景及旋转;样式作用于整个文本节点。选中与取消选中、菜单均有动画,尊重系统减少动态效果偏好。 -- 底部 T 添加纯文本,便签按钮添加文本卡片。纯文本没有卡片外框、连接点或四向加号。 -- 底部图形按钮打开左侧图标浮层,可搜索并分批加载 2313 个 Lucide 图标;图标支持移动、缩放、旋转和颜色设置,无需额外图标目录服务。 -- 大小、角度、样式、字号和图标标识通过独立 Canvas 快照接口存入数据库,并保留本地恢复记录及 JSON 导出。保存使用独立版本及 If-Match,串行提交并合并等待中的更新,冲突后停止自动写入。分区框、名称模式和成员归属已随 Core 022086b 部署支持,使用同一个快照接口保存。 -- 业务类继承为 `CanvasElement → CardElement → TextCardElement / ImageCardElement / ScriptCardElement`;`PlainTextElement` 和 `IconElement` 直接继承 `CanvasElement`。基础元素拥有缩放和旋转能力,卡片层追加连线和分支能力。React 组件通过组合共享 `TransformableNode`。 -- `/dashboard/{workspace}/{board}` 的两个 ID 分别标识工作空间和图谱。 -- 回归:`node --experimental-strip-types --test tests/canvas-style.test.mjs tests/canvas-geometry.test.mjs tests/canvas.test.mjs tests/workspace.test.mjs`。 +## 会话期限 -## 框选区 - -底部「添加框选区」插入可调整大小的分区;选中框可改名,名称提供隐藏、固定屏幕字号、随画布缩放三种模式。拖动边框或名称移动分区,右下角调整大小。框在内容下方,内部空白仍可平移画布。 - -只有实际拖入的内容才建立归属,以内容中心进入分区为准;拖出后退出,重叠区域优先较小分区。移动分区只带动已有成员,不会自动收纳覆盖到的其他内容;删除分区会保留成员位置与内容。单次拖动及归属变化可以一步撤销,分区设置和归属进入草稿与 JSON。 - -## 服务端快照与 slug 路由 - -Core 57aab71 提供基础快照,022086b 已补齐分区框并部署。前端允许 `/workspaces/{workspace}/materials/{board}/canvas`,保留 ETag / If-Match,只为 Canvas 路由放宽至 5 MiB。打开画布并行读取材料信息和快照;读取失败时不开放空白编辑器,避免覆盖远程数据。800ms 防抖提交,更新串行化;失败停止自动写入并提供重试、导出和重新读取。旧草稿和未同步恢复记录都需明确选择,默认使用服务端快照。 - -URL 的 workspace 与 board 可分别使用 ID 或 slug,支持全部四种组合。以接口返回的 canonical ID 做身份校验、本地恢复隔离与保存路径,侧栏高亮、标题及改名支持 slug;新侧栏链接优先使用可用 slug。改名不自动更改 slug。 - -分区字段契约:[CORE_FRAME_EXTENSION.md](docs/CORE_FRAME_EXTENSION.md)。前端已按该契约保存分区,无需额外数据库结构或接口。 - -首页现提供可点击的画布预览、三段创作说明、FAQ 和本地演示入口;已登录访问 `/` 仍跳转无画布的 `/dashboard`。 - -## 最新编辑交互 - -文本卡片、脚本和纯文本统一为单击选中、双击进入编辑(采用系统双击窗口);选中后隔很久再单击不会误进编辑。非编辑状态可直接拖动整个文本区域,不再使用纯文本左上角拖动按钮;Esc 或失焦退出编辑。卡片外部标题可双击改名。 - -底部同时提供图片卡片与纯图片入口;纯图片按原图比例插入,没有卡片标题/连接点,支持整图拖动、等比缩放及旋转。分区工具栏显式提高层级,修复可见但无法操作的问题。完整继承关系见 [CANVAS_ELEMENTS.md](docs/CANVAS_ELEMENTS.md)。 - -会话策略已按确认方案准备前后端实现:30 天闲置续期、90 天绝对期限,Core PR #10 已随主线 7847167 的部署 #889 成功上线;既有过期会话不会复活,有效会话可在下一次鉴权时续期。详见 [SESSION_LIFETIME.md](docs/SESSION_LIFETIME.md)。纯图片快照配套 Core PR #11(codex/plain-images)已随部署 #889 上线;其余已部署节点保存保持兼容。 +会话为可撤销的随机令牌。30 天闲置续期、90 天绝对期限由 Core 控制,代理按剩余期限更新 HttpOnly Cookie;既有过期会话不恢复。详细本地说明为 `docs/SESSION_LIFETIME.md`。 diff --git a/VALIDATION.md b/VALIDATION.md index d522426..f7a4043 100644 --- a/VALIDATION.md +++ b/VALIDATION.md @@ -1,179 +1,27 @@ -# 本地验收记录 +# Canvas V2 验收记录(2026-11-06) -## 双击编辑、纯图片、节点名称、完整图标库与会话策略(2026-11-06) +## 前端检查 -- CanvasTextEditor 统一文本卡片、脚本与纯文本:单击选择,原生 dblclick 编辑;拖动不进入编辑,Esc/失焦退出。纯文本移除左上角手柄,选中后整块区域可拖动。 -- 右下角缩放符号在未选中时也显示,拖动先选中;抓手模式仍可见但不响应缩放。连接点保持仅选中显示。 -- 新增 PlainImageElement / PlainImageNode,按图片原始尺寸等比插入及缩放,支持旋转、拖动与分区归属,无卡片标题和连线。新增可撤销节点改名。 -- 修复 Frame 的 NodeToolbar 默认 zIndex=0 被画布交互层覆盖的问题,工具栏显式设为 zIndex=100、pointerEvents=auto;分区名称输入和模式选择可响应。 -- Lucide 改为包内完整动态目录,2317 个名称合并数字别名后提供 2313 个唯一存储标识。支持中英文关键词、每批 80 项和按需图标模块加载;测试校验全部标识满足 Core 格式且无冲突。 -- 前端 Cookie 改为 30 天上限,并按 Core 的 X-Foundry-Session-Expires-In 续期;保留 HttpOnly/SameSite/HTTPS Secure,401 和非法续期头不续期。Core 策略使用独立工作分支 PR,不直接推送或合并 Core main。 -- 51 项前端测试、类型检查及隔离生产构建通过;接口冒烟验证 30 天 Cookie、绝对期限附近缩短到剩余秒数、非法头与 401 不续期,并保持已有快照和路由流程通过。 -- Core 会话策略提交 34c461a 的远程 CI #881 通过,包含构建、迁移和隔离数据库测试;PR #10 已合并。纯图片配套 Core 提交 460b05f 的 CI #883 同样通过,PR #11 也已合并;最新主线 7847167 的 CI #888 与部署 #889 均通过。代理未调用合并或部署操作。 -- 未访问项目浏览器,因此双击/拖动手感、弹层视觉与真实账户体验仍待用户试用;前端按首页、工作台、文档分组提交,统一推送;原有 next-env.d.ts 与 pnpm 未提交文件保留。 +- `npm run typecheck` 通过。 +- `node --experimental-strip-types --test tests/*.test.mjs`:69 项测试通过,覆盖既有行为、V2 无损往返、端口方向与曲线字段校验、投影与逆变换、局部/远端三方合并、草稿兼容、八向 Shift 等比、LCA 连接计划和父子共同拖动不重复位移。 +- `CANVAS_UI_TOOLS=/private/tmp/endoleap-ui-check node tests/canvas-ui-check.mjs`:真实 React Flow + DOM 事件回归通过,使用临时安装的 esbuild/jsdom,不访问浏览器。 +- DOM 覆盖:旋转与撤销重做、三图标标签切换、八向手柄、连线选择/曲线点/marker/动画;子画布独立读取与保存、收起/展开、创建及撤销重做;模型按输出类型过滤、文本传播、图片原节点回写、取消与生成期间保留几何。 +- 新跨图连接以实际 Handle 鼠标事件触发,验证能力门槛、最低共同祖先、准确的所有参与版本,以及已经提交但丢失响应后的相同 body/key 重试;不会重复创建端口或边。 +- API 冒烟覆盖 V2 Accept/If-Match/Idempotency-Key、子图/连接/运行命令白名单、事件分页参数、带 Cookie 的图片二进制响应、MIME 限制、multipart 上传、原有 OAuth/会话/同源校验和 5 MiB 限制。 +- 隔离生产构建 `npm run build -- --webpack` 及最终 `node tests/api-smoke.mjs` 均通过;构建在临时副本执行,不改变正在使用的开发服务器产物。 -## 框选区、服务端快照、slug 与新首页(2026-11-06) +## 后端与测试画布证据边界 -- 框选区采用独立 FrameElement,支持命名、hidden/fixed/scaled 三种标签显示,固定字号通过逆视图缩放保持屏幕尺寸;分区不旋转、不连线。成员记录使用绝对坐标和 frameId,只有内容拖动结束时更新归属;移动框及调整大小不会收纳被覆盖内容。 -- 单个或多选内容拖入/拖出可加入/退出,移动已有分区会同步移动成员,显式选中的成员不会移动两遍。删除框保留成员,撤销可恢复框与归属。Frame/成员/标签设置均通过草稿和 JSON 往返校验。 -- 对照已部署 Core 57aab71 接入 Canvas GET/PATCH、5 MiB、ETag/If-Match 与独立版本。加载失败不创建空快照;自动保存串行提交并合并等待更新,拒绝写入后停止自动重试。412 提供重新读取和导出,其他错误允许明确重试。 -- 旧草稿与未同步恢复记录通过原生模态对话框由用户选择,默认服务端优先;草稿与服务端不一致时不自动覆盖。导航/关闭时同步写入本地恢复记录,成功服务器保存后标记为干净备份。 -- workspace 与 board 的 ID/slug 四种组合可读取同一个 canonical 资源,恢复记录与保存使用 canonical ID;侧栏高亮、当前名称及改名修复不再把 slug 当作 ID 比较。 -- 首页以 CSS Module 隔离重做:留白与分栏、黑色按钮、衬线文字、SVG 线稿与交互预览、三段工作方式、FAQ、真实本地演示入口;匿名/登录入口分流保持。包含窄屏样式和减少动态效果设置。 -- 46 项自动测试通过,包含分区拖入/拖出及不误收纳、成员移动与删除撤销、三种标签序列化、串行保存/合并/失败重试/412停止、旧草稿迁移决策,以及全部 ID/slug 路径组合。 -- 隔离 Webpack 生产构建及扩展 API 冒烟通过;HTTP 回归覆盖 Canvas 读写、1 MiB 以上快照还原、ETag/If-Match、412、5 MiB 限制、四种 slug/ID 组合,既有会话/路由/88 个品牌资源保持通过。 -- Core 随后通过 PR #9 交付分区字段,022086b 的部署 #877 已确认成功。对照 normalizeCanvas 与后端分区测试核对 frame/frameId/labelMode,前端代理模拟往返覆盖三种标签及成员归属;真实账号的数据库写入及跨设备恢复尚未操作,没有修改真实用户数据。 -- 按用户约定未访问项目浏览器;实际视觉、拖动手感和真实账户跨设备验收待用户反馈。本阶段尚未 commit / push。 +用户交付 Core `b4e537b` 部署、ECS CI #912/#913,以及 `/Users/zzyang/foundry-canvas-v2-migration-summary.json`:指定父画布版本 209→210,1 个真实子画布、2 个显式成员,11 个节点和 2 条边保留;重新读取和幂等验证通过。 -## 移除大加号与连线端点修正(2026-11-06) +用户提供的隔离后端真实模型验收包括文本生成和 PNG 图片产物;本轮前端没有再次调用线上模型、修改测试画布或重复迁移。模型 ID 在运行时读取,不硬编码验收 ID。 -- 选中时不再放大连接点或显示加号,四边中心的小连接点仅选中时淡入显示,取消选中后淡出,保留拖线及点击打开节点类型菜单。未选中节点仍可作为连线终点;隐藏连接点不支持发起拖线。 -- React Flow 默认曲线端点落在 Handle 外沿,选中时尺寸变化和旋转后的测量会带来偏移。新增共享卡片边缘锚点计算,已连接曲线与拖线预览都直接使用实际尺寸、位置、角度计算起终点,兼容旧无名左右连接。 -- 新增几何回归覆盖缩放、旋转、选中状态变化和路径两端坐标。31 项测试、类型检查、隔离 Webpack 生产构建和差异检查通过。按约定未访问项目浏览器,改动暂留本地。 +新增 connections 扩展已对照用户提供的接口说明和本机 Core `07fd678` 源码实现;主线合并为 `630ed42`。生产是否可调用以 capabilities.subgraphs.connections=true 及后端部署确认作为门槛,不把文档或合并本身当作已部署。 -## 首页按登录态分流(2026-11-06) +## 限制和交付状态 -- `/` 改为服务端页面:无 foundry_session 时显示 landing page;有 Cookie 时向 Core profile 验证,有效会话 307 跳转 `/dashboard`;失效或无法验证的会话显示 landing page。验证请求 no-store,不把会话值传入客户端组件。 -- 原有 landing 页面移至 src/components/landing-page.tsx,保留 FAQ 交互;`/dashboard` 继续使用现有无画布工作台,图谱独立路由不变。 -- 类型检查、独立临时目录的 Webpack 生产构建通过;生产输出将 `/` 标记为动态路由。 -- 完整 API 冒烟通过,新增有效会话首页 307/Location、私有响应缓存、会话值不进入响应内容、无会话和失效会话 landing,以及 dashboard 不渲染 canvas 的检查;既有 API 和 88 个品牌资源回归通过。 -- 未访问项目浏览器。本次保留本地改动,按用户最新要求将提交/推送留到合适阶段统一管理。 - -## 连接交互、样式栏与图标(2026-11-06) - -- 移除“保存在此浏览器”状态行;卡片连接点恢复四边中心,未选中显示点,选中后动画变为加号。同一个 React Flow Handle 支持拖出贝塞尔线;点击加号打开文本/图片/脚本菜单,图片需用户选图,取消不会创建空节点。选中卡片拖线到空白处,在释放位置打开菜单。 -- 连接点大小与加号显隐、选中边框有 CSS 过渡,样式栏与菜单提供 Motion 进入/退出动画;尊重减少动态效果偏好。 -- 标题移到框外,textarea 充满卡片内部,卡片与纯文本均覆盖全局聚焦阴影,去掉额外内框。 -- 删除八向缩放和常驻旋转柄,仅保留右下角缩放符号。旋转从样式栏开启,拖动元素旋转,点击外部或 Esc 退出;Shift 吸附 15°。 -- 样式支持颜色、背景、字体、字号、粗体、斜体、下划线、对齐及行距;节点样式可撤销、导入导出和本地保存。根据用户后续反馈,纯文本改为宽高独立调整、自动换行,缩放不改变字号;仅图标保持等比。 -- 底部图标入口打开左侧浮层,提供 45 个 Lucide 图标及中英文名称搜索,支持插入、缩放、旋转、颜色和 JSON 往返。浮层使用 Portal,避免被展开的工作空间侧栏遮住。 -- 核对 Core main e8ed5e4 的 content/materials 实现:board.content 目前只允许 schema_version,无法存储节点或样式。尚未对接不存在的 API,也未修改真实账户或服务端数据。完整接口建议、字段和验收标准见 docs/CORE_CANVAS_API.md。 -- 29 项自动测试及类型检查通过;覆盖选择不同类型的分支、空白处释放位置、样式完整往返与撤销、非法样式拒绝、图标继承与缩放,以及已有画布/工作空间流程。 -- 最终源代码的隔离 Webpack 生产构建、完整 API/路由/88 个品牌资源冒烟回归均通过;未改动正在使用的开发服务缓存。 -- 按用户既定安排未访问项目浏览器,实际拖线手感、弹层位置与动画视觉由用户试用反馈;自动状态测试不代表浏览器交互验收。 - -## 元素继承、缩放、旋转与纯文本(2026-11-06) - -- 新增业务元素类继承,卡片与纯文本共享基础变换;纯文本不提供连接点或四向分支。 -- 选中元素提供八向缩放与旋转柄;几何计算使用旋转后的局部坐标,保持对侧锚点。纯文本按比例缩放字号,支持大小、角度、字号的草稿/JSON 持久化和单次手势撤销。 -- 类型检查通过;24 项自动测试通过,包含八个方向在多个角度下的锚点不变、纯文本缩放限制、旋转吸附、继承能力、导入兼容与撤销。 -- 独立临时目录的 Webpack 生产构建通过;完整 API/路由/88 个品牌资源冒烟回归通过,未改写正在使用的开发服务缓存。 -- 遵循用户安排未访问项目浏览器,实际拖动手感和旋转后的连线视觉仍由用户试用反馈。 - -- 生产构建:Next.js 16.4.5,TypeScript 检查及 6 个页面静态生成成功。 -- API 会话测试:HttpOnly / SameSite Cookie、令牌不返回前端、带会话代理、401、跨站拒绝、非法 Origin、路径白名单、OAuth challenge 校验通过。 -- 线上轻量检查:profile 401;空登录参数 400;Slack 与 Google 启动登录均返回 302,目标分别为 slack.com、accounts.google.com。未执行真人账户登录。 -- 浏览器:登录/注册切换、演示画布创建、搜索无结果、打开画布、笔记保存、待办勾选,以及未登录访问 /dashboard 返回 /login。 -- 1440px 首页和 390px 首页/工作台:页面宽度等于视口宽度,未发现横向溢出。首页图片全部加载。 -- Figma:原为 DIRECT;已添加 figma.com、figmausercontent.com 代理规则并从实际连接确认 DomainSuffix 生效。验证时代理组当前节点为香港 Y03。 -- 本机生产预览:http://localhost:17321(只监听本机)。 -- 尚未完成:真人 OAuth / 邮箱验证完整回路、后端项目回调地址匹配、完整编辑器布局持久化、分享/收藏/Agent。 -- 本项目仅本地创建,未新建 Gitea 远程仓库、未提交或推送。 - - -后续性能检查与首次创建流程修复已完成,最终构建、API smoke、4 项流程回归与隔离浏览器验收通过。详见 PERFORMANCE.md。 - -## 真实账号创建验收 - -已在用户已登录的 Chrome 中打开 http://localhost:17321/dashboard。初始空间列表为空;使用新建画布流程提交用户原先填写的「周末的灵感」后,创建了「个人空间」和该画布。完整刷新页面后,侧栏和主列表仍显示该画布,确认读取到后端持久化记录。此项为真实账号验收,补充此前只完成隔离模拟测试的限制。 - -## App Router 迁移验收(2026-11-05) - -- 全部页面迁入 `src/app`;根布局提供全局样式、`zh-CN`、标题及描述。工作台通过 `next/navigation` 读取演示参数,入口提供 Suspense 边界。 -- 两个 API 改为 Route Handlers,保留会话 Cookie、Origin 校验、路径和查询参数白名单、15 秒超时、请求取消及 Server-Timing;显式保留 1 MiB 请求体限制。 -- `npm run build -- --webpack`:通过,构建结果全部为 App Router 路由。 -- 默认 `npm run build`:当前环境禁止 Turbopack CSS 工作进程绑定本地端口而失败;没有更改项目默认构建器。清理迁移前 `.next` 缓存后,Webpack 编译、类型检查和静态生成均通过。 -- `npm run typecheck`:通过。 -- `node --experimental-strip-types --test tests/workspace.test.mjs`:4 项通过。 -- `node tests/api-smoke.mjs`:通过。覆盖首页、登录、登录回调、真实及演示工作台的 HTTP 响应、语言/标题/描述/viewport、404、Cookie 与令牌隔离、HTTPS Secure Cookie、登录态代理、跨站及非法 Origin、路径及查询白名单、请求方法、非法 JSON、超限请求体、Google/Slack 302、无效 challenge 和 204 退出清除 Cookie。 -- 本次未重跑浏览器交互与响应式验收:浏览器连接不可用;未进行真人账户登录或部署验证。 -- 迁移前 Pages Router 快照已先推送到现有仓库 main;本次迁移按用户明确授权直接更新 main,不创建 PR。 - -## 合并侧栏与新建图谱(2026-11-05) - -- 移除最左侧独立 icon 栏,侧栏使用纵向 flex;顶部操作、搜索栏和底部账户按钮内部使用横向 flex。 -- Lucide 图标按钮以 16×16px 图标、上下 4px / 左右 6px 内边距实现;收起后只保留左上角浮框,可恢复侧栏或新建页面。 -- 账户弹出框使用原生 Popover,支持浏览器的外部点击及 Escape 关闭;菜单提供实际用户资料、空间切换和退出。 -- 创建契约核对 Foundry Core 主线 `e8ed5e4` 的 materials/workspaces 实现:`kind=board`,后端生成 ID、workspace 归属、版本;前端提供默认名称“未命名图谱”,首次创建时可先建立“个人空间”。 -- 类型检查通过;6 项 workspace 单元测试通过,包含默认名称、ID 和 workspace 保留、路由编码、首次建空间及失败重试。 -- 在独立临时目录执行 `npm run build -- --webpack` 成功,未改写当前开发服务的构建缓存。 -- 隔离生产构建的 API 冒烟测试通过,新增模拟 Foundry 的创建 201、会话转发、默认名称与 schema、按生成 ID 重新读取及图谱路由 200 检查。 -- 按用户要求不访问浏览器,本次未验证实际界面交互或使用真实账户创建图谱;浏览器和线上验收由用户后续反馈。 - -## 侧栏动画、字体与 Google 头像(2026-11-06) - -- 使用固定版本 Motion for React 14.9.1:侧栏宽度和位置以 240ms 缓动变化,浮框以 140ms 淡入淡出;系统减少动态效果偏好下立即切换。收起时侧栏设为 inert/aria-hidden,焦点转移到展开按钮。 -- 侧栏主文字改为 14px、#37352f;列表内边距改为 4px 8px,行高 20px,最小高度 30px;个人空间等分组标题保持原来的 11px 浅灰色。 -- 账户弹出框重新设置分区、工作空间图标、用户资料、头像、分隔线、边框和阴影,保留原生 Popover 的 Escape 与外部点击关闭;账户设置和新建工作空间接入实际 profile PATCH / workspaces POST。 -- Foundry 主线已保存 OAuth picture 并通过 profile.avatar_url 返回,本次补齐前端类型和头像渲染,无需修改后端。图片失败或缺失时回退首字,图片使用 no-referrer。 -- 类型检查与 6 项 workspace 测试通过;隔离临时目录的 Webpack 生产构建及 API 冒烟检查通过,新增头像 URL 透传、资料修改后保留头像的检查。 -- 沿用用户“不访问浏览器,由用户试用反馈”的要求,未运行本次浏览器视觉、动画及真实 Google 头像加载验收;未修改真实账户资料。 - -## 全屏画布与原始品牌资源(2026-11-06) - -- 删除图谱顶部标题栏及相关 CSS;画布 `position: fixed; inset: 0; height: 100dvh`,侧栏和浮框保留在更高层级,加载与错误消息以浮层显示。 -- 收起侧栏后,将原新建按钮位置替换为 Logo 链接,目标为主页 `/`;Logo 提供 1x/2x/3x PNG,展开侧栏的新建按钮不变。 -- 从旧 PROMPT 提交 `47467874841ea7f7b4b362d4ec0e66f236c6a7d6` 复制 88 个原始 PNG/ICO/SVG,逐一核对 Git blob 哈希完全一致;PNG 尺寸为 16px 至 2048px。 -- 全局 Brand 组件与根布局 favicon/Apple Touch 元信息使用迁入资源。 -- 按用户约定不访问浏览器,视觉与点击交互由用户试用反馈;自动检查覆盖资源 HTTP 响应和字节一致性、图标元信息及既有接口流程。 -- 本次类型检查、隔离 Webpack 生产构建、6 项 workspace 测试及扩展 API 冒烟检查均通过;88 个品牌资源的 HTTP 状态和响应字节与本地原文件一致。 - -## React Flow 与悬浮工具栏(2026-11-06) - -- 根因:此前画布仅为静态点阵 div,没有安装或渲染 React Flow。本次加入固定版本 `@xyflow/react@12.12.1` 和官方样式,使用 ReactFlowProvider / ReactFlow 实现真正的平移、缩放、节点拖动、连线与多选。 -- 默认启用空白处左键拖动平移和触控板滚动平移,Ctrl/Meta 激活滚动缩放,支持捏合与空格平移;抓手模式关闭节点拖动,并让节点编辑区域的指针事件穿透到画布。 -- 底部工具栏提供选择、抓手、文字、图片、脚本、创作模板与导入;右上角提供撤销重做、缩放、100%、适应画布及导出。没有接入虚假的 AI 生成或会员功能。 -- 左上角增加 Google 头像入口;共享账户 Popover 移出会变为 inert 的侧栏,收起时将弹出位置改为左上角,保留原账户设置、空间切换及退出功能。 -- 节点、连线和视图暂存 localStorage,按 workspace 与 board 分离;这不是服务端同步。导入校验节点/边/图片来源/视图/大小,导出提供 JSON 备份;存储失败明确提示。 -- 13 项自动测试通过:节点位置变化与撤销重做、删除连线清理、文字编辑事务、导入导出、恶意图片与无效图谱拒绝、草稿隔离、历史上限及既有工作空间流程。 -- 类型检查、隔离 Webpack 生产构建和完整 API 冒烟回归通过;原有 88 个品牌资源与账户/Foundry 创建接口保持通过。 -- 遵循用户不访问浏览器的安排,未做实际浏览器拖动、触控手势和视觉验收;不将状态测试表述为已完成浏览器手感验收。 - -## 悬浮栏标题与尺寸统一(2026-11-06) - -- 当前图谱标题通过共享 ActiveBoardProvider 从图谱页面传到左上悬浮栏,按图谱 ID 与 workspace 校验,避免错显上一张图谱;不增加重复 API 请求,长标题省略并保留完整 title 属性。 -- 统一所有悬浮栏的 surface 样式:16px 图标、28×24px border-box 按钮、12px 字体、4px 内边距、2px 间隙、8px 圆角;浮框头像缩为 16px,账户菜单内头像仍保持原尺寸。 -- 删除 canvas 工具栏的 content-box 与 8px/9px 内边距覆盖,缩放文字和帮助按钮采用同一高度、字号及容器;下方和右侧距屏幕边缘统一 12px。 -- 沿用不访问浏览器的安排,本次验证以类型检查、生产构建和现有接口回归为准,未进行浏览器视觉验收。 -- 本次类型检查、隔离 Webpack 生产构建和完整 API/品牌资源冒烟检查均通过。 - -## 悬浮栏 32px 图标试用(2026-11-06) - -- 按用户要求将共享悬浮栏图标、Logo 和头像改为 32×32px,按钮改为 44×40px,主文字 14px;保留 4px 容器内边距,整体高度 50px。 -- Logo 切换到 32/64/128px 原始资源,账户与帮助弹出框偏移同步调整,窄屏工具栏允许横向滚动。 -- 类型检查和差异检查通过。本次为尺寸试调,未重复生产构建,未访问浏览器。 - -## 修正 32×32 总尺寸(2026-11-06) - -- 用户澄清 32×32 指包含 padding 的按钮总尺寸。统一使用 border-box 的 32×32px 按钮,内部图标、Logo 与浮框头像为 20px,保留上下 4px、左右 6px 内边距;主文字仍为 14px。 -- 公共图标按钮默认尺寸同步修正,悬浮栏整体高度为 42px;账户和帮助弹出框偏移随之调整,保留高清 Logo 资源。 -- 类型检查及差异检查通过;本次尺寸修正未重复构建或访问浏览器。 - -## 悬浮栏间距微调(2026-11-06) - -- 控件间距从 2px 增加到 4px,统一应用于左上、右上、底部及帮助悬浮栏;保持图标按钮含 padding 总尺寸 32×32px。 -- 差异检查通过;仅调整 CSS 间距,未重复构建或访问浏览器。 - -## 左上角标题改名(2026-11-06) - -- 标题交互为 idle → selected → editing,第一次点击选中,第二次点击编辑;支持 Enter/失焦保存、Esc 取消、F2 编辑与输入法组合输入保护。 -- 改名前 GET 最新图谱,PATCH 携带带引号的 If-Match 版本、原 description/content;成功后更新共享标题、侧栏列表及导出使用的名称。演示模式写入本地列表,跨图谱切换不会覆盖新图谱的标题。 -- 名称去除首尾空格,拒绝空名及超过 300 UTF-8 字节的名称;版本冲突不自动覆盖或重试,失败保留输入。 -- 类型检查和 16 项画布/工作空间测试通过,新增原内容保留、名称校验和版本冲突不重试的检查。 -- 按既定约定不访问浏览器,未进行实际点击与输入法交互验收。 -- 隔离 Webpack 生产构建和完整 API 冒烟检查通过;新增改名 PATCH/If-Match、204 响应、重新读取名称和内容保留检查通过。 - -## 参照右上控件调整分组留白(2026-11-06) - -- 右上现有尺寸和间距保持不变;左上与底部采用稍宽的 6px 横向间隙和横向内边距,保持 4px 竖向内边距与 32×32px 按钮。 -- 左上在标题前后增加与右上相同的分隔线,去掉标题额外外边距;三处分隔线改为共享样式。 -- 类型检查和差异检查通过;本次 CSS 留白试调未重复生产构建或访问浏览器。 - -## 选中节点四向加号(2026-11-06) - -- 节点 selected 时显示上/下/左/右四个加号,未选中时隐藏;加号使用 nodrag/nopan 并阻止指针事件传播,避免触发节点或画布拖动。 -- 点击按实际测量的节点尺寸在对应方向新增文字节点并自动连线,重复添加避开已有节点;选择与视图跟随新节点,添加及连线组成一步撤销。 -- 四侧使用命名 Handle 与 Loose 连接模式;保留旧无名连接点用于已有连线,导入导出校验并保留新的 sourceHandle/targetHandle。 -- 类型检查与 18 项自动测试通过,新增四向位置、连接方向持久化、一步撤销和重复添加避让测试。 -- 按用户既定安排不访问浏览器,本次未进行视觉和实际点击验收。 -- 隔离 Webpack 生产构建与完整 API/路由/品牌资源冒烟回归均通过。 +- 按用户约定未访问项目浏览器,未进行视觉布局、真实拖动手感与真实登录会话的浏览器验收。 +- 子图关系备份不能作为普通单图快照导入其他画布;导出包含完整 owner 文档集合,避免仅备份父图或重复保存投影。 +- 网络未知结果保留请求并暂停编辑;普通版本冲突保留本地草稿,不强推覆盖。 +- Headless 库尚未创建,提取方案已改写在本地忽略的 docs/HEADLESS_CANVAS_API_DRAFT.md;已替代的分区/元素/后端需求文档已清理。会话策略文档仍保留。 +- 原有 next-env.d.ts、pnpm-lock.yaml 和 pnpm-workspace.yaml 不属于本次交付,保留未提交。 diff --git a/src/app/api/foundry/[...path]/route.ts b/src/app/api/foundry/[...path]/route.ts index 2e516af..ae3f570 100644 --- a/src/app/api/foundry/[...path]/route.ts +++ b/src/app/api/foundry/[...path]/route.ts @@ -3,10 +3,12 @@ import { NextRequest, NextResponse } from "next/server"; const allowed = /^(profile|auth\/(login|register|verify-email|exchange|me|logout)|workspaces(?:\/[a-zA-Z0-9_-]+(?:\/(?:materials(?:\/[a-zA-Z0-9_-]+(?:\/canvas)?)?|search))?)?)$/; +const canvasRoute = /^workspaces\/[a-zA-Z0-9_-]+\/materials\/[a-zA-Z0-9_-]+\/canvas(?:\/(?:connections|capabilities|subgraphs(?:\/(?:migrate|[a-zA-Z0-9_-]+\/(?:members|dissolve)))?|transactions\/[a-zA-Z0-9_-]+\/undo|ai\/(?:models|runs(?:\/[a-zA-Z0-9_-]+(?:\/(?:cancel|apply))?)?)))?$/; +const assetRoute = /^workspaces\/[a-zA-Z0-9_-]+\/assets(?:\/[a-zA-Z0-9_-]+)?$/; const bodyLimit = 1024 * 1024; // Route Handlers do not have the Pages Router bodyParser size limit. -async function readBody(req: NextRequest, limit = bodyLimit) { +async function readBody(req: NextRequest, limit = bodyLimit, binary = false) { const reader = req.body?.getReader(); if (!reader) return {}; const chunks: Uint8Array[] = []; @@ -25,8 +27,9 @@ async function readBody(req: NextRequest, limit = bodyLimit) { } finally { reader.releaseLock(); } - if (!size) return {}; - return JSON.parse(Buffer.concat(chunks).toString("utf8")); + if (!size) return binary ? new Uint8Array() : {}; + const bytes = Buffer.concat(chunks); + return binary ? bytes : JSON.parse(bytes.toString("utf8")); } async function handler( @@ -46,11 +49,13 @@ async function handler( } } const path = (await params).path.join("/"); - if (!allowed.test(path)) return json({ error: "not_found" }, 404); + if (!allowed.test(path) && !canvasRoute.test(path) && !assetRoute.test(path)) return json({ error: "not_found" }, 404); + const asset = assetRoute.test(path); + const canvas = canvasRoute.test(path); let requestBody: unknown; if (req.method !== "GET") { try { - requestBody = await readBody(req, path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit); + requestBody = await readBody(req, asset ? 21 * 1024 * 1024 : path.endsWith("/canvas") ? 5 * 1024 * 1024 : bodyLimit, asset); } catch (error) { const tooLarge = error instanceof Error && error.message === "body_too_large"; return json({ error: tooLarge ? "body_too_large" : "invalid_json" }, tooLarge ? 413 : 400); @@ -58,15 +63,16 @@ async function handler( } const origin = process.env.FOUNDRY_API_ORIGIN || "https://api.foundry.entroia.com"; const project = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap"; - const headers: Record = { "Content-Type": "application/json" }; + const headers: Record = { "Content-Type": asset ? (req.headers.get("content-type") || "application/octet-stream") : "application/json" }; + if (canvas) headers.Accept = "application/vnd.foundry.canvas+json;version=2"; const session = req.cookies.get("foundry_session")?.value; if (session) headers.Authorization = "Bearer " + session; - for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match"]) { + for (const h of ["x-foundry-code", "x-foundry-verifier", "if-match", "idempotency-key"]) { const value = req.headers.get(h); if (value !== null) headers[h] = value; } const query = new URLSearchParams(); - for (const key of ["kind", "sort", "q", "limit", "offset"]) { + for (const key of ["kind", "sort", "q", "limit", "offset", "after_seq", "status", "include_descendants", "include_subgraphs", "cursor"]) { const values = req.nextUrl.searchParams.getAll(key); if (values.length === 1 && values[0].length < 300) query.set(key, values[0]); } @@ -78,14 +84,29 @@ async function handler( { method: req.method, headers, - body: req.method === "GET" ? undefined : JSON.stringify(requestBody || {}), + body: req.method === "GET" ? undefined : asset ? requestBody as BodyInit : JSON.stringify(requestBody || {}), signal: AbortSignal.any([AbortSignal.timeout(15000), req.signal]), redirect: "manual", cache: "no-store", }, ); + const retryAfter = upstream.headers.get("retry-after"); + if (retryAfter) responseHeaders.set("Retry-After", retryAfter); const etag = upstream.headers.get("etag"); if (etag) responseHeaders.set("ETag", etag); + const schema = upstream.headers.get("x-canvas-schema-version"); + if (schema) responseHeaders.set("X-Canvas-Schema-Version", schema); + const renewal = upstream.headers.get(SESSION_EXPIRY_HEADER); + if (session && upstream.status !== 401 && renewal !== null && /^\d+$/.test(renewal)) { + const secure = process.env.NODE_ENV === "production" && req.headers.get("x-forwarded-proto") === "https"; + responseHeaders.set("Set-Cookie", sessionCookie(session, sessionMaxAge(renewal), secure)); + } + if (asset && upstream.ok && req.method === "GET") { + const type = upstream.headers.get("content-type") || ""; + if (!/^image\/(png|jpeg|webp|gif)(?:;|$)/i.test(type)) return json({ error: "invalid_asset" }, 502); + responseHeaders.set("Content-Type", type); responseHeaders.set("X-Content-Type-Options", "nosniff"); + return new NextResponse(upstream.body, { status: upstream.status, headers: responseHeaders }); + } const headersReceived = performance.now(); const body = upstream.status === 204 ? null : await upstream.json(); responseHeaders.set( diff --git a/src/components/canvas/ai-panel.tsx b/src/components/canvas/ai-panel.tsx new file mode 100644 index 0000000..57ef981 --- /dev/null +++ b/src/components/canvas/ai-panel.tsx @@ -0,0 +1,51 @@ +"use client"; +import { useEffect, useState } from "react"; +import { NodeToolbar, Position } from "@xyflow/react"; +import { ArrowUp, Square, Sparkles } from "lucide-react"; +import { api, errorText } from "@/lib/api"; +import { canvasPath } from "@/lib/canvas-sync"; +import { CANVAS_ACCEPT, managedImageSource } from "@/lib/canvas-v2"; +import type { CanvasNode } from "@/lib/canvas"; +import type { CanvasModel, useCanvasAI } from "./use-canvas-ai"; +import styles from "./canvas.module.css"; +export function AIPanel({ id, node, workspace, boardId, ai, save }: { id: string; node: CanvasNode; workspace: string; boardId: string; ai: ReturnType; save: (patch: NonNullable) => void }) { + const [models, setModels] = useState([]); + const [model, setModel] = useState(node.data.ai?.modelId || ""); + const [prompt, setPrompt] = useState(node.data.ai?.prompt || ""); + const [propagate, setPropagate] = useState(node.data.ai?.propagate || false); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(true); + const [starting, setStarting] = useState(false); + const image = ["image", "plain-image"].includes(node.data.kind); + const relevant = [...ai.runs.values()].filter((run) => run.steps?.some((step) => step.board_id === boardId && step.node_id === node.id)); + const run = relevant.at(-1), step = run?.steps?.find((step) => step.board_id === boardId && step.node_id === node.id); + const busy = starting || step?.status === "queued" || step?.status === "running"; + useEffect(() => { + if (!ai.live) { setLoading(false); return; } + const controller = new AbortController(); + setLoading(true); setError(""); + api<{ models: CanvasModel[] }>(canvasPath(workspace, boardId) + "/ai/models", { signal: controller.signal, headers: { Accept: CANVAS_ACCEPT } }).then((result) => { + const compatible = result.models.filter((model) => model.output_modalities.includes(image ? "image" : "text")); + setModels(compatible); setModel((id) => compatible.some((model) => model.id === id) ? id : compatible.find((model) => model.available)?.id || ""); + }).catch((error) => { if (!controller.signal.aborted) setError(errorText(error)); }).finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [workspace, boardId, image, ai.live]); + const config = () => ({ enabled: true, modelId: model, prompt, propagate, parameters: {} }); + const persist = () => { if (model && ai.live) save(config()); }; + const output = run?.outputs?.find((item) => item.step_id === step?.step_id)?.output; + const textOutput = run?.events?.filter((event) => event.type === "step.output" && event.node_id === node.id && event.board_id === boardId).at(-1)?.output?.text; + return
event.stopPropagation()} onClick={(event) => event.stopPropagation()} onDoubleClick={(event) => event.stopPropagation()} onKeyDown={(event) => event.stopPropagation()}> +
{image ? "图像创作" : "文本创作"}{node.data.title}
+