mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-30 04:44:28 +08:00
Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2002dff78a | |||
| 2afe2f3898 | |||
| 7a48f441cf | |||
| 8babf7faa0 | |||
| fe1ba781d1 | |||
| b926feeb14 | |||
| bd8cf318f7 | |||
| a9243b6c2e | |||
| b628b742d5 | |||
| d76594a7e7 | |||
| fa58ec4715 | |||
| 5a607bea6c | |||
| 88e86c0566 | |||
| d2141d5f59 | |||
| eda3481533 | |||
| 0526290c8f | |||
| ed32e44e69 | |||
| 0f5eb1fbcb | |||
| 20844d5e99 | |||
| ea67a5abf5 | |||
| c6f8b1e394 | |||
| 1f6a652799 | |||
| 40c47dd7ff | |||
| 92fd0ce129 | |||
| b3b02f75a9 | |||
| b6e9954c0a | |||
| c23a819f27 | |||
| 6aa9c8bddc | |||
| bde33e7816 | |||
| edc7667830 | |||
| f76f81c4c7 | |||
| 87ea3a3954 | |||
| ac126a61b9 | |||
| d6d6b7bb8d | |||
| 84fc159d4f | |||
| 6c61081698 | |||
| f8191d13b2 | |||
| a0287a5e34 | |||
| 0179724467 | |||
| 49851f2b5f | |||
| c81bb8b651 | |||
| b7d0fe10bc | |||
| d91e67a5ad | |||
| d566d5cc46 | |||
| 8fb8d0b224 | |||
| 0ca35f46ca | |||
| ccf6412a7c | |||
| 4236af0c58 | |||
| 8bcedfe51b | |||
| 5e1fd7a825 | |||
| 062e4569aa | |||
| b5dc9c8a87 | |||
| 75c22c2f9f | |||
| 97bb330de1 | |||
| bdca6b0a5c | |||
| d4130bbb79 | |||
| 0e1edd1484 | |||
| 5107be0acf | |||
| 219b3e84db | |||
| a3411cf9d9 | |||
| 430d1bd956 | |||
| cf0561d12a | |||
| e6dcd7a832 | |||
| 187c499ef9 | |||
| cf1ea2646f | |||
| 57db0fe752 | |||
| c5f53454aa | |||
| ca6efdf0c3 | |||
| a2586ffe34 | |||
| 5a27aae99e | |||
| 66961ce10f | |||
| 0c4288b832 | |||
| 093260f7b2 | |||
| 805573b980 | |||
| e6829333c5 | |||
| 2959b5d340 | |||
| 2f9776bee3 | |||
| 4beadc6161 | |||
| 13c508bc34 | |||
| 5f627d5baf | |||
| 3f179a607a | |||
| 2d2c7e3d5e | |||
| 3f623ea70c | |||
| 7ac2ab351d | |||
| b6e75c225e | |||
| 75aafc115f | |||
| 8d3f244524 | |||
| 88510223eb | |||
| eef4d96787 | |||
| f0db29d8e3 | |||
| 3963c6eebf | |||
| c57f7d61a7 |
@@ -24,7 +24,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: web
|
working-directory: web
|
||||||
run: bun install --frozen-lockfile
|
run: bun install
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
working-directory: web
|
working-directory: web
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Publish Official Plugins
|
||||||
|
|
||||||
|
# 构建官方插件并把产物(bundle + 清单)强推到孤儿分支 plugins-dist。
|
||||||
|
# 与 github-pages.yml 一致,在打版本 tag 时触发 —— 发版时 web 与官方插件一起发布。
|
||||||
|
# 前端经 jsDelivr 从该分支远程拉取;构建产物不进 main、不进 git。
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: publish-plugins
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
working-directory: plugins/canvas/registry
|
||||||
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build official plugins
|
||||||
|
working-directory: plugins/canvas/registry
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Publish to plugins-dist branch
|
||||||
|
working-directory: plugins/canvas/registry/dist
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
git init -q
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
git checkout -q -b plugins-dist
|
||||||
|
git add -A
|
||||||
|
git commit -q -m "publish official plugins @ ${GITHUB_SHA::7}"
|
||||||
|
git push -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" plugins-dist
|
||||||
@@ -12,3 +12,6 @@ web/dist
|
|||||||
|
|
||||||
**/dist
|
**/dist
|
||||||
web/public/plugins
|
web/public/plugins
|
||||||
|
plugins/canvas/*/dist
|
||||||
|
plugins/canvas/*/node_modules
|
||||||
|
web/public/plugins
|
||||||
@@ -51,6 +51,8 @@
|
|||||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||||
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
||||||
|
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
|
||||||
|
- 画布内的操作按钮(如面板里的「添加」「导出」「选择」等)默认用扁平无底色样式:透明背景、仅 `hover:bg-black/5 dark:hover:bg-white/10` 轻微反馈,靠图标+文字表达,不要用 `theme.toolbar.activeBg`(`#e7e5df`/`#3a3631`)或 `theme.node.fill` 之类的灰色作为按钮填充底色。灰色 `activeBg` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
|
||||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||||
|
|
||||||
|
|||||||
+100
-3
@@ -2,15 +2,113 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [修复] 画布 Agent 默认通过画布节点生成内容,仅在用户明确指定时使用 Codex 内置生图并将结果展示到对话和当前画布。
|
||||||
|
+ [优化] Agent 面板标题、功能标签与新对话操作合并为单行顶部栏,减少面板空间占用。
|
||||||
|
+ [新增] 画布 Agent 支持请求批准、自动审查和完全访问三档 Codex 权限,并可在对话中审批文件、命令与网络访问。
|
||||||
|
+ [修复] Agent 回复中的本地绝对文件路径改为在系统文件管理器中定位,不再被浏览器错误拼接为 localhost 地址。
|
||||||
|
+ [优化] Agent Markdown 代码块与外链确认弹窗改为紧凑中文样式,改善右侧面板阅读体验。
|
||||||
|
+ [优化] Agent 思考摘要支持 Markdown 渲染,改善强调、列表和代码内容的阅读效果。
|
||||||
|
+ [修复] Agent 对话启用 Codex 可读思考摘要,并修复一轮结束或刷新恢复后思考摘要、命令和工具过程记录缺失的问题。
|
||||||
|
+ [优化] Agent 思考摘要和命令执行改为无边框紧凑折叠行,并保留 Codex 实时返回的具体思考摘要。
|
||||||
|
+ [调整] Agent 工具确认模式移至对话输入框,并默认使用自动确认。
|
||||||
|
+ [优化] Agent 用户图片消息改为紧凑缩略图展示,并支持单击放大预览。
|
||||||
|
+ [修复] 修复 Agent 图片预览打开后在对话区域产生额外空行的问题。
|
||||||
|
+ [优化] Canvas Agent 内置 Codex CLI 升级至 0.145.0。
|
||||||
|
+ [修复] Canvas Agent 停止任务改为中断当前 Codex turn,避免终止整个 App Server 进程。
|
||||||
|
+ [新增] Canvas Agent 支持通过 `--debug` 输出详细日志并按日期保存到本地日志文件。
|
||||||
|
+ [优化] Canvas Agent Debug 日志改为简洁的纯文本单行格式。
|
||||||
|
+ [优化] 精简网页与本地 Agent 的 HTTP 诊断日志,过滤轮询和流式重复事件并改用任务摘要展示。
|
||||||
|
+ [优化] Agent 对话移除双方头像,用户消息改为右侧透明排版,工具活动改为图标与状态同行的紧凑卡片;输入框上方居中实时展示带平滑数字动画的最新一次模型调用 Token 用量。
|
||||||
|
+ [优化] Canvas Agent 流式回复改为增量合并传输并隔离消息列表渲染,减少长回复和长会话中的重复更新与界面卡顿。
|
||||||
|
+ [优化] Agent 对话新增思考摘要、计划、命令、搜索和文件变更时间线,工具卡片改为中文摘要与用户可读详情,不再展示原始 JSON。
|
||||||
|
+ [新增] Agent 对话支持 Codex 结构化任务进度,在输入区上方独立折叠展示并逐项实时更新待处理、进行中和已完成状态。
|
||||||
|
+ [优化] Agent 长时间等待时显示当前处理阶段、已等待时长和停止提示,并避免新建空会话反复记录历史读取失败。
|
||||||
|
+ [修复] 修复 Agent 发送后的运行提示闪退及回复需要切换标签页才显示的问题。
|
||||||
|
+ [修复] Agent 在模型繁忙等任务失败时会立即结束等待状态,并在对话和历史中展示中文错误原因与重试建议。
|
||||||
|
+ [优化] Agent 历史记录支持多选批量删除,点击记录可直接进入对话。
|
||||||
|
+ [调整] 进入画布并连接 Agent 时默认开启空白新对话,不再自动加载上一次会话,历史对话改为由用户主动选择恢复。
|
||||||
|
+ [优化] Agent 在画布内接收任务时优先直接操作当前画布,不再无故查询画布列表或重复进入画布。
|
||||||
|
+ [修复] 修复 Agent 用户图片附件位置偏移,历史同步或重新打开对话后仍保留本地缩略图,并且不再回显内部附件说明。
|
||||||
|
+ [修复] 修复画布全局复制快捷键拦截 Agent 对话和节点详情文本复制的问题。
|
||||||
|
+ [优化] Canvas Agent 改为从独立指令文件初始化当前工作目录的 AGENTS.md,不再为每轮消息重复拼接前置提示词。
|
||||||
|
+ [优化] 重构 Canvas Agent 源码目录,按 Agent、画布、服务与通用工具拆分模块职责,提升代码可维护性。
|
||||||
|
|
||||||
|
## v0.11.0 - 2026-07-28
|
||||||
|
|
||||||
|
+ [新增] 画布文本生成新增推理强度设置,支持默认调用与自定义调用脚本读取所选档位。
|
||||||
|
+ [新增] 生图与视频工作台的参考资产区域支持直接拖动文件上传。
|
||||||
|
+ [新增] 配置与用户偏好支持通过配置文件导入和导出。
|
||||||
|
+ [新增] 模型渠道新增火山方舟协议。
|
||||||
|
+ [优化] 画布左侧元素列表新增图片放大预览操作。
|
||||||
|
+ [优化] 图片遮罩、切图与裁剪编辑支持常用快捷操作并修复高分辨率图片预览闪烁。
|
||||||
|
+ [优化] 提示词详情弹窗改为上下布局并限制显示尺寸。
|
||||||
|
+ [修复] 画布组装提示词浮层限制正文高度,长提示词改为区域内滚动查看。
|
||||||
|
+ [修复] 修复画布节点提示词过长时无法使用鼠标滚轮滚动的问题。
|
||||||
|
+ [修复] 修复画布重试时组装提示词重复以及切换类型调用模型不一致的问题。
|
||||||
|
+ [修复] 修复生成完成后再次选中画布节点时提示词不再回显的问题。
|
||||||
|
|
||||||
|
## v0.10.0 - 2026-07-25
|
||||||
|
|
||||||
|
+ [新增] 提示词来源新增BananaPromptQuicker,并支持添加自定义标准 JSON 来源。
|
||||||
|
+ [新增] Agent 新增统一状态查询,支持查看画布、生图工作台和视频工作台任务进度。
|
||||||
|
+ [调整] 内置提示词来源改为读取ImagePrompts 统一JSON 数据,不再由画布端分别解析。
|
||||||
|
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
|
||||||
|
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
|
||||||
|
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
|
||||||
|
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
|
||||||
|
+ [优化] 提示词中心标题居中、搜索输入增加防抖,并采用左侧筛选、右侧内容的双栏布局。
|
||||||
|
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
|
||||||
|
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
|
||||||
|
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
|
||||||
|
|
||||||
|
## v0.9.0 - 2026-07-17
|
||||||
|
|
||||||
|
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
|
||||||
|
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
|
||||||
|
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
|
||||||
|
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
|
||||||
|
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
|
||||||
|
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
|
||||||
|
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
|
||||||
|
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
|
||||||
|
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
|
||||||
|
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
|
||||||
|
|
||||||
|
## v0.8.2 - 2026-07-16
|
||||||
|
|
||||||
|
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||||
|
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
|
||||||
|
+ [修复] 画布节点提示词输入框补上悬停文本光标。
|
||||||
|
|
||||||
|
## v0.8.1 - 2026-07-16
|
||||||
|
|
||||||
|
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
|
||||||
|
+ [优化] 3D 全景(1.1.0)支持上传与 AI 生成并升级查看器;
|
||||||
|
+ [优化] HTML 节点(1.2.0)迁移到统一交互开关;
|
||||||
|
+ [优化] 便利贴(1.1.0)可拖动移动、自选颜色、移除资源角标与衍生功能;
|
||||||
|
+ [优化] SVG 节点(1.1.0)透明背景融入画布、可拖动、去除默认值;
|
||||||
|
+ [优化] 画布节点渲染性能:memo 化节点回调,交互时不再全量重渲染;
|
||||||
|
+ [修复] 修复 Markdown 节点在点击/移动视角时重复渲染导致图片反复请求的问题;
|
||||||
|
+ [修复] 修复插件版本号显示不更新,插件面板新增可升级绿点提醒。
|
||||||
|
|
||||||
|
## v0.8.0 - 2026-07-15
|
||||||
|
|
||||||
|
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
||||||
|
+ [新增] 插件开发 SDK,可用 TypeScript 开发画布节点插件。
|
||||||
|
+ [新增] 新增 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||||
|
+ [新增] 官方插件注册表:节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||||
|
+ [新增] 在画布右上角工具栏新增「节点插件」入口。
|
||||||
|
+ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。
|
||||||
|
|
||||||
## v0.7.1 - 2026-07-15
|
## v0.7.1 - 2026-07-15
|
||||||
|
|
||||||
+ [修复] 修复通过 `crypto.randomUUID` 不可用导致页面白屏报错的问题,改用 nanoid 生成 id。
|
+ [修复] 修复页面白屏报错的问题。
|
||||||
|
|
||||||
## v0.7.0 - 2026-07-14
|
## v0.7.0 - 2026-07-14
|
||||||
|
|
||||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
|
||||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||||
@@ -18,7 +116,6 @@
|
|||||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||||
+ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。
|
|
||||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
||||||
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ FROM nginx:1.27-alpine
|
|||||||
|
|
||||||
COPY --from=web-build /app/web/dist /usr/share/nginx/html
|
COPY --from=web-build /app/web/dist /usr/share/nginx/html
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||||
|
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://trendshift.io/repositories/50077?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-50077" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50077/daily?language=TypeScript" alt="basketikun%2Finfinite-canvas | Trendshift" width="250" height="55"></a>
|
<a href="https://trendshift.io/repositories/50077?utm_source=repository-badge&utm_medium=badge&utm_campaign=badge-repository-50077" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/repositories/50077" alt="basketikun%2Finfinite-canvas | Trendshift" width="250" height="55"/></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
@@ -25,17 +25,32 @@
|
|||||||
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
|
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用。
|
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新。
|
||||||
>
|
>
|
||||||
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
|
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
|
||||||
|
|
||||||
|
## 赞助商
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="190" align="center">
|
||||||
|
<a href="https://www.atlascloud.ai/zh?utm_source=github&utm_medium=link&utm_campaign=infinite-canvas"><img src="assets/atlascloud.svg" width="163" alt="Atlas Cloud"></a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href="https://www.atlascloud.ai/zh?utm_source=github&utm_medium=link&utm_campaign=infinite-canvas">Atlas Cloud</a> is a full-modal AI inference platform that gives developers a single AI API to access video generation, image generation, and LLM APIs. Instead of managing multiple vendor integrations, you connect once and get unified access to 300+ curated models across all modalities. Check out <a href="https://www.atlascloud.ai/console/coding-plan">Atlas Cloud's new coding plan promotion</a> for more budget-friendly API access.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
## 核心功能
|
## 核心功能
|
||||||
|
|
||||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成。
|
||||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
||||||
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
||||||
|
- 插件系统:支持通过 URL 动态安装 / 启用 / 更新 / 卸载远程节点插件,并提供 TypeScript SDK 自行开发画布节点插件。
|
||||||
|
- 自定义接口调用:可自定义生图 / 视频接口的调用方式,灵活适配各类中转站与自建服务。
|
||||||
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
|
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
|
||||||
|
|
||||||
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
|
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
|
||||||
@@ -68,16 +83,7 @@ docker compose up -d
|
|||||||
|
|
||||||
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
||||||
|
|
||||||
## New API 自动配置
|
如果默认的OpenAI接口调用方式与您的API不同,可自定义生图/视频脚本调用。
|
||||||
|
|
||||||
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
|
|
||||||
|
|
||||||
```text
|
|
||||||
https://canvas.best?apiKey={key}&baseUrl={address}
|
|
||||||
```
|
|
||||||
|
|
||||||
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
|
|
||||||
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
|
|
||||||
|
|
||||||
## 效果展示
|
## 效果展示
|
||||||
|
|
||||||
@@ -108,20 +114,9 @@ https://canvas.best?apiKey={key}&baseUrl={address}
|
|||||||
|
|
||||||
## 赞助支持
|
## 赞助支持
|
||||||
|
|
||||||
<div align="center">
|
本项目长期开放广告赞助合作,欢迎品牌 / 产品投放,你的支持是持续更新的动力!
|
||||||
|
|
||||||
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力!
|
有广告赞助意向请通过上方联系方式沟通。
|
||||||
|
|
||||||
<br>
|
|
||||||
|
|
||||||
<a href="https://ifdian.net/a/basketikun">
|
|
||||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<br>
|
|
||||||
<br>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 社区支持
|
## 社区支持
|
||||||
|
|
||||||
@@ -133,6 +128,10 @@ https://canvas.best?apiKey={key}&baseUrl={address}
|
|||||||
|
|
||||||
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
|
你可以在遵守 AGPL-3.0 的前提下自由使用、复制、修改和分发本项目。如果你分发修改后的版本,或将其作为网站、SaaS 等网络服务提供给他人使用,需要向对应用户公开基于本项目形成的完整源代码,继续使用 AGPL-3.0 协议,并保留原项目的作者、版权、许可证和来源说明。
|
||||||
|
|
||||||
|
本项目禁止未经授权的闭源商用。如果你希望将无限画布用于商业项目,请尊重开源,遵循 AGPL-3.0 协议,继续开源基于本项目修改或开发的对应代码,回馈开源社区;如果无法公开对应代码,请联系作者购买商业授权后闭源使用。
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
|
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
|
||||||
|
|||||||
+16
@@ -31,6 +31,22 @@ Please include:
|
|||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
|
### Canvas node plugins
|
||||||
|
|
||||||
|
The canvas supports third-party node plugins loaded from a remote URL. By
|
||||||
|
design, an installed plugin's code runs directly inside the web app with full
|
||||||
|
access to the page, including locally stored data such as AI API keys. This is
|
||||||
|
an intentional trade-off for extensibility, and the installer shows a warning
|
||||||
|
before installing. Therefore:
|
||||||
|
|
||||||
|
- Only install plugins from sources you trust.
|
||||||
|
- Reports that a *malicious plugin* can access page data or API keys are **out
|
||||||
|
of scope** — that is the documented behavior of the trust model.
|
||||||
|
- Reports **in scope** include: the app loading/executing plugin code without
|
||||||
|
the install confirmation, a plugin escaping its declared node type to break
|
||||||
|
core app integrity in ways not implied by "runs in the page", or the plugin
|
||||||
|
source cache being writable by an unrelated origin.
|
||||||
|
|
||||||
Examples of in-scope reports:
|
Examples of in-scope reports:
|
||||||
|
|
||||||
- Cross-site scripting or token exfiltration in the web app.
|
- Cross-site scripting or token exfiltration in the web app.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.3 KiB |
@@ -8,6 +8,14 @@
|
|||||||
npx -y @basketikun/canvas-agent
|
npx -y @basketikun/canvas-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
需要排查连接、线程、Codex app-server 或工具调用问题时,可开启 Debug 模式:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx -y @basketikun/canvas-agent --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
Debug 日志会以 `[DEBUG][HH:mm:ss]` 等传统格式输出到终端,并按启动日期保存到 `~/.infinite-canvas/logs/canvas-agent-YYYY-MM-DD.log`。终端日志带级别颜色,文件日志为纯文本;日志包含 HTTP、SSE、线程、turn、Codex app-server 和工具调用事件,token 与图片 Data URL 会自动隐藏。
|
||||||
|
|
||||||
本仓库开发时也可以直接运行:
|
本仓库开发时也可以直接运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -126,7 +134,7 @@ default_tools_approval_mode = "approve"
|
|||||||
|
|
||||||
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
|
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
|
||||||
|
|
||||||
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
|
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把任务进度、计划、搜索、文件修改与工具操作整理为中文过程时间线。
|
||||||
|
|
||||||
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
|
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Infinite Canvas Agent
|
||||||
|
|
||||||
|
你正在帮助用户操作 Infinite Canvas 网站。
|
||||||
|
|
||||||
|
- 用户要求操作画布时,默认目标就是网页当前已经打开的画布。需要了解内容时先使用 `canvas_get_state`;读取成功后直接在该画布执行任务,不要调用 `canvas_list_projects`,也不要用 `site_navigate` 重复进入画布。
|
||||||
|
- 只有用户明确要求查看、选择或切换其他画布,或者 `canvas_get_state` 明确提示当前没有已连接画布时,才使用 `canvas_list_projects` 和 `site_navigate`。`site_navigate` 可跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`。
|
||||||
|
- 修改当前画布时根据任务使用已配置的 infinite-canvas MCP 工具;复杂批量改动使用 `canvas_apply_ops`。
|
||||||
|
- 用户要求把上传附件放入画布或作为生成参考图时,必须先用 `canvas_create_attachment_nodes` 创建真实图片节点,再把节点 ID 传给生成流程,不要创建空图片占位节点。
|
||||||
|
- 生图与视频工作台分别使用 `workbench_image_*`、`workbench_video_*` 工具;提示词和素材分别使用 `prompts_search`、`assets_*` 工具。
|
||||||
|
- 用户要求生成图片、视频、音频或文本时,默认调用对应的 `canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_generate_text`,通过当前画布的生成节点完成任务。
|
||||||
|
- 只有用户明确要求使用“Codex 内置生图”“ImageGen 技能”或意思明确相同的能力时,才使用 Codex 自带的 `imagegen`;不要因为用户只说“生成图片”就自行改用内置生图。内置生图完成后,其结果会由 Canvas Agent 自动展示到对话并插入当前画布,无需再创建空节点或重复生成。
|
||||||
|
- 只有用户明确说要在生图/视频工作台生成时,才使用 `workbench_image_*`、`workbench_video_*`。生成任务提交后应说明已经在画布或工作台开始生成,不要在实际没有结果时声称“已生成”。
|
||||||
|
- 需要生成内容时直接调用对应生成工具,不要绑定特定业务场景,不要模拟鼠标点击,不要要求用户手动复制 JSON。
|
||||||
+61
-8
@@ -6,8 +6,9 @@
|
|||||||
"name": "@basketikun/canvas-agent",
|
"name": "@basketikun/canvas-agent",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"@openai/codex": "^0.139.0",
|
"@openai/codex": "0.145.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"winston": "^3.19.0",
|
||||||
"zod": "^3.25.0",
|
"zod": "^3.25.0",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -19,6 +20,10 @@
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@colors/colors": ["@colors/colors@1.6.0", "", {}, "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA=="],
|
||||||
|
|
||||||
|
"@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="],
|
||||||
|
|
||||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||||
|
|
||||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||||
@@ -75,19 +80,21 @@
|
|||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||||
|
|
||||||
"@openai/codex": ["@openai/codex@0.139.0", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0.tgz", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.139.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.139.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.139.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.139.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.139.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.139.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-wr2fRE+fzW0CjEbfFsLh1ftarVEcw0CMLWS7QyA0nyOz5qacQPVq3cq2+/U7oEbwm1TOqoi0Fm1nxniB5FkpmA=="],
|
"@openai/codex": ["@openai/codex@0.145.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.145.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.145.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.145.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.145.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.145.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.145.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-/PSPSFujjjmiyVFvG2yu/grOFhsWdokTH8t2KGWhXSo/M5n/dIDsnbsnO82/7bLtIoDuzQf7ATBUMWqPWQINlQ=="],
|
||||||
|
|
||||||
"@openai/codex-darwin-arm64": ["@openai/codex@0.139.0-darwin-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-darwin-arm64.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-o+0ZKWwgDFMMLO7rwinzO0PQsgK+Vme1pMN2GeAxsX29ZgGZcyPICfpJbeGSUO1mb2a36Skjx6nfdRnxMY0r7w=="],
|
"@openai/codex-darwin-arm64": ["@openai/codex@0.145.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-h6aQ0UxnaP8mIM/9/qPAH9MNkRliJo88toq1T36IxNM2L5JSU0TFamu+MZn7YkFgDsrp0RfiI+97Tm8AVVxqtA=="],
|
||||||
|
|
||||||
"@openai/codex-darwin-x64": ["@openai/codex@0.139.0-darwin-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-darwin-x64.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-9gkBWzu6DB2rqU4DbpxD3DE5bofGpsK46Lp0h0I+bKWc2IIcxvSi8K2utKmBLoJCbKrn4JQu7dFNGRqEfENung=="],
|
"@openai/codex-darwin-x64": ["@openai/codex@0.145.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-FCYzVKCa9VoLtg9gVyzKpqylonfgZrfcWZN6HsXAZPeuo8CukdMqdgTUOhDn2V6h3MbqS0z6VqQVKUllN/yKhA=="],
|
||||||
|
|
||||||
"@openai/codex-linux-arm64": ["@openai/codex@0.139.0-linux-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-arm64.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-tBQE5lZciRHeWZGuURgjP9S717MvTIpQMc593+DNxY2LQxozkngOkzFSQd1+/UmQKGrCqdFLu5irIwPXpSZyEw=="],
|
"@openai/codex-linux-arm64": ["@openai/codex@0.145.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-8OLcPXaAol/FOrRoDxWhIiHIFa73KRsM41EKocjRZOwiT4TcelzJWn3dHyiuSb7teWF25rrslvSPyvhULYRRCQ=="],
|
||||||
|
|
||||||
"@openai/codex-linux-x64": ["@openai/codex@0.139.0-linux-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-x64.tgz", { "os": "linux", "cpu": "x64" }, "sha512-14UgzDS+X4crkvdt6S02A/ZZOrS8ZyWiuTRpguCtnhNamb7unSuDxy86BWgpAl3sqiTaN2CP8VLyp2ohQ8Nbzw=="],
|
"@openai/codex-linux-x64": ["@openai/codex@0.145.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-u8w8LLv3DvsfrDCoswLIemZ0SoNEXyi511WsfFsSiYUazk9qMsB/NtU8N9vhAfN7mZAxLFoMex4v66JjHuZWwA=="],
|
||||||
|
|
||||||
"@openai/codex-win32-arm64": ["@openai/codex@0.139.0-win32-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-arm64.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-nlwRjsYotH1Rtqu/Q0VwQbIeO2UX1mkHK84Ov9qn/hl29QqqoBtno0tRyqIPbkXFIVQuWiAYXlV3ugLwH5fTrQ=="],
|
"@openai/codex-win32-arm64": ["@openai/codex@0.145.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-sub61rjEFevi1i3Zx7nAd4JM5XxoNFqMqFc5LfTo2xSI8ixHjFvEYDFDXwXOftT04n3Ht1Wh271ioUZpDiEjEg=="],
|
||||||
|
|
||||||
"@openai/codex-win32-x64": ["@openai/codex@0.139.0-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ=="],
|
"@openai/codex-win32-x64": ["@openai/codex@0.145.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-u0h9lk094CaXRSqE34SBW2dRaQTPa6fASXqehczWH9QdsU62mBsiAgAdp6tCG4i+YzPmmhjD8FdXNnYGNmwuMg=="],
|
||||||
|
|
||||||
|
"@so-ric/colorspace": ["@so-ric/colorspace@1.1.6", "", { "dependencies": { "color": "^5.0.2", "text-hex": "1.0.x" } }, "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw=="],
|
||||||
|
|
||||||
"@types/body-parser": ["@types/body-parser@1.19.6", "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
"@types/body-parser": ["@types/body-parser@1.19.6", "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||||
|
|
||||||
@@ -109,12 +116,16 @@
|
|||||||
|
|
||||||
"@types/serve-static": ["@types/serve-static@2.2.0", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
"@types/serve-static": ["@types/serve-static@2.2.0", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||||
|
|
||||||
|
"@types/triple-beam": ["@types/triple-beam@1.3.5", "", {}, "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="],
|
||||||
|
|
||||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||||
|
|
||||||
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||||
|
|
||||||
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||||
|
|
||||||
|
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||||
|
|
||||||
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
"bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||||
@@ -123,6 +134,14 @@
|
|||||||
|
|
||||||
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||||
|
|
||||||
|
"color": ["color@5.0.3", "", { "dependencies": { "color-convert": "^3.1.3", "color-string": "^2.1.3" } }, "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@3.1.3", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@2.1.1", "", {}, "sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg=="],
|
||||||
|
|
||||||
|
"color-string": ["color-string@2.1.4", "", { "dependencies": { "color-name": "^2.0.0" } }, "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg=="],
|
||||||
|
|
||||||
"content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
"content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||||
|
|
||||||
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||||
@@ -143,6 +162,8 @@
|
|||||||
|
|
||||||
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||||
|
|
||||||
|
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
|
||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||||
@@ -169,8 +190,12 @@
|
|||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||||
|
|
||||||
|
"fecha": ["fecha@4.2.3", "", {}, "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw=="],
|
||||||
|
|
||||||
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"fn.name": ["fn.name@1.1.0", "", {}, "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw=="],
|
||||||
|
|
||||||
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
@@ -203,6 +228,8 @@
|
|||||||
|
|
||||||
"is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
"is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||||
|
|
||||||
|
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||||
|
|
||||||
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
"jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||||
@@ -211,6 +238,10 @@
|
|||||||
|
|
||||||
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||||
|
|
||||||
|
"kuler": ["kuler@2.0.0", "", {}, "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="],
|
||||||
|
|
||||||
|
"logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="],
|
||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
"media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
"media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||||
@@ -233,6 +264,8 @@
|
|||||||
|
|
||||||
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||||
|
|
||||||
|
"one-time": ["one-time@1.0.0", "", { "dependencies": { "fn.name": "1.x.x" } }, "sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g=="],
|
||||||
|
|
||||||
"parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
"parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
@@ -249,10 +282,16 @@
|
|||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||||
|
|
||||||
|
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||||
|
|
||||||
|
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||||
|
|
||||||
"send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
"send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||||
@@ -273,10 +312,18 @@
|
|||||||
|
|
||||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||||
|
|
||||||
|
"stack-trace": ["stack-trace@0.0.10", "", {}, "sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg=="],
|
||||||
|
|
||||||
"statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
"statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||||
|
|
||||||
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
"text-hex": ["text-hex@1.0.0", "", {}, "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||||
|
|
||||||
|
"triple-beam": ["triple-beam@1.4.1", "", {}, "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg=="],
|
||||||
|
|
||||||
"tsx": ["tsx@4.22.4", "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
"tsx": ["tsx@4.22.4", "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||||
|
|
||||||
"type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
"type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||||
@@ -287,10 +334,16 @@
|
|||||||
|
|
||||||
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||||
|
|
||||||
|
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||||
|
|
||||||
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||||
|
|
||||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="],
|
||||||
|
|
||||||
|
"winston-transport": ["winston-transport@4.9.0", "", { "dependencies": { "logform": "^2.7.0", "readable-stream": "^3.6.2", "triple-beam": "^1.3.0" } }, "sha512-8drMJ4rkgaPo1Me4zD/3WLfI/zPdA9o2IipKODunnGDcuqbHwjsbB79ylv04LCGGzU0xQ6vTznOMpQGaLhhm6A=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -9,18 +9,22 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist",
|
"dist",
|
||||||
|
"agent-instructions.md",
|
||||||
"README.md"
|
"README.md"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
|
"debug": "tsx src/index.ts --debug",
|
||||||
|
"test": "tsx --test src/canvas/session.test.ts",
|
||||||
"build": "tsc -p tsconfig.json",
|
"build": "tsc -p tsconfig.json",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"prepack": "npm run build"
|
"prepack": "npm run build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||||
"@openai/codex": "^0.139.0",
|
"@openai/codex": "0.145.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"winston": "^3.19.0",
|
||||||
"zod": "^3.25.0"
|
"zod": "^3.25.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
import { AGENT_PROMPT } from "../config.js";
|
||||||
|
import { errorMessage } from "../utils/value.js";
|
||||||
|
import type { AgentEmit } from "./types.js";
|
||||||
|
|
||||||
|
/** 使用 Claude CLI 执行一次带 Canvas Agent 工具的任务。 */
|
||||||
|
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||||
|
const fullPrompt = withAgentPrompt(prompt);
|
||||||
|
if (!fullPrompt) return;
|
||||||
|
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", fullPrompt], emit);
|
||||||
|
if (child) pipeJsonLines(child, emit, "claude");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 为 Claude CLI 请求拼接 Canvas Agent 指令。 */
|
||||||
|
function withAgentPrompt(prompt: string) {
|
||||||
|
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 Claude CLI 的 JSON Lines 输出转换为 Agent 事件。 */
|
||||||
|
function pipeJsonLines(child: ReturnType<typeof spawn>, emit: AgentEmit, agent: string) {
|
||||||
|
let out = "";
|
||||||
|
child.stdout?.on("data", (chunk) => {
|
||||||
|
out += chunk.toString();
|
||||||
|
const lines = out.split(/\r?\n/);
|
||||||
|
out = lines.pop() || "";
|
||||||
|
lines.filter(Boolean).forEach((line) => {
|
||||||
|
try {
|
||||||
|
emit("agent_event", { agent, ...JSON.parse(line) });
|
||||||
|
} catch {
|
||||||
|
emit("agent_event", { agent, type: "raw", text: line });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||||
|
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||||
|
child.on("close", (code) => emit("agent_done", { agent, code }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动外部 Agent CLI,并将同步启动异常转换为事件。 */
|
||||||
|
function spawnAgent(name: string, args: string[], emit: AgentEmit) {
|
||||||
|
try {
|
||||||
|
return spawn(name, args, { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", windowsHide: true });
|
||||||
|
} catch (error) {
|
||||||
|
emit("agent_error", { message: errorMessage(error) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
import { VERSION } from "../config.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { field, type JsonRecord } from "../utils/value.js";
|
||||||
|
import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
|
||||||
|
import type { AgentEmit, AgentPermissionMode } from "./types.js";
|
||||||
|
|
||||||
|
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
|
||||||
|
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||||
|
type ItemDeltaParams = { threadId: string; turnId: string; itemId: string; delta: string };
|
||||||
|
type PendingDelta = { delta: string; itemType: string; params: ItemDeltaParams; timer: ReturnType<typeof setTimeout> };
|
||||||
|
|
||||||
|
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const STREAM_UPDATE_INTERVAL_MS = 40;
|
||||||
|
|
||||||
|
/** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
|
||||||
|
export class CodexAppClient {
|
||||||
|
private nextId = 1;
|
||||||
|
private buffer = "";
|
||||||
|
private currentThreadId = "";
|
||||||
|
private currentTurnId = "";
|
||||||
|
private textByItem = new Map<string, string>();
|
||||||
|
private lastUsage: unknown = null;
|
||||||
|
private pending = new Map<number, PendingRequest>();
|
||||||
|
private activeTurns = new Map<string, PendingRequest>();
|
||||||
|
private completedTurns = new Map<string, Error | null>();
|
||||||
|
private pendingDeltas = new Map<string, PendingDelta>();
|
||||||
|
private plansByTurn = new Map<string, CodexPlanUpdate>();
|
||||||
|
private approvalRequests = new Map<string, { id: number; method: string; params: JsonRecord }>();
|
||||||
|
|
||||||
|
/** 保存 app-server 子进程和事件出口。 */
|
||||||
|
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
||||||
|
|
||||||
|
/** 启动并初始化 Codex app-server。 */
|
||||||
|
static async start(emit: AgentEmit, onExit: () => void) {
|
||||||
|
logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
|
||||||
|
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
||||||
|
const client = new CodexAppClient(child, emit);
|
||||||
|
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
||||||
|
child.stderr?.on("data", (chunk) => {
|
||||||
|
const text = chunk.toString();
|
||||||
|
logger.warn("Codex app-server stderr", { text });
|
||||||
|
emit("agent_log", { text });
|
||||||
|
});
|
||||||
|
child.on("error", (error) => {
|
||||||
|
logger.error("Codex app-server process error", error);
|
||||||
|
emit("agent_error", { message: error.message });
|
||||||
|
});
|
||||||
|
child.on("exit", (code) => {
|
||||||
|
logger.warn("Codex app-server exited", { code });
|
||||||
|
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
||||||
|
onExit();
|
||||||
|
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
||||||
|
});
|
||||||
|
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
||||||
|
client.notify("initialized");
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建新的 Codex 线程。 */
|
||||||
|
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||||
|
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
||||||
|
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复已有 Codex 线程。 */
|
||||||
|
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||||
|
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
|
||||||
|
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询 Codex 线程列表。 */
|
||||||
|
listThreads(params: CodexRequestParams<"thread/list">) {
|
||||||
|
return this.request("thread/list", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取指定 Codex 线程。 */
|
||||||
|
readThread(threadId: string, includeTurns = true) {
|
||||||
|
return this.request("thread/read", { threadId, includeTurns });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档指定 Codex 线程。 */
|
||||||
|
archiveThread(threadId: string) {
|
||||||
|
return this.request("thread/archive", { threadId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回指定线程在当前进程中收到的最新任务计划。 */
|
||||||
|
planUpdates(threadId: string) {
|
||||||
|
return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理已归档线程的任务计划缓存。 */
|
||||||
|
clearPlanUpdates(threadId: string) {
|
||||||
|
this.plansByTurn.forEach((item, turnId) => {
|
||||||
|
if (item.threadId === threadId) this.plansByTurn.delete(turnId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 启动一个 Codex turn 并等待完成通知。 */
|
||||||
|
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) {
|
||||||
|
this.currentThreadId = threadId;
|
||||||
|
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
|
||||||
|
const turnId = turn.id;
|
||||||
|
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||||
|
this.currentTurnId = turnId;
|
||||||
|
onTurn?.(turnId);
|
||||||
|
const completed = this.completedTurns.get(turnId);
|
||||||
|
if (this.completedTurns.has(turnId)) {
|
||||||
|
this.completedTurns.delete(turnId);
|
||||||
|
this.currentThreadId = "";
|
||||||
|
this.currentTurnId = "";
|
||||||
|
if (completed) throw completed;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中断当前正在运行的 Codex turn。 */
|
||||||
|
async interruptCurrentTurn() {
|
||||||
|
const threadId = this.currentThreadId;
|
||||||
|
const turnId = this.currentTurnId;
|
||||||
|
if (!threadId || !turnId) return false;
|
||||||
|
try {
|
||||||
|
logger.warn("Interrupting active Codex turn", { threadId, turnId });
|
||||||
|
await this.request("turn/interrupt", { threadId, turnId });
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Failed to interrupt Codex turn", { error, threadId, turnId });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回复网页端已经确认的 Codex 权限请求。 */
|
||||||
|
resolveApproval(requestId: string, decision: string) {
|
||||||
|
const request = this.approvalRequests.get(requestId);
|
||||||
|
if (!request) return false;
|
||||||
|
this.approvalRequests.delete(requestId);
|
||||||
|
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
|
||||||
|
const result = request.method === "item/permissions/requestApproval"
|
||||||
|
? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
|
||||||
|
: { decision };
|
||||||
|
this.write({ id: request.id, result });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
|
||||||
|
private request<Method extends CodexRequestMethod>(method: Method, params: CodexRequestParams<Method>) {
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.write({ id, method, params });
|
||||||
|
return new Promise<CodexRequestResult<Method>>((resolve, reject) => this.pending.set(id, { resolve: (result) => resolve(result as CodexRequestResult<Method>), reject }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 发送无需响应的 JSON-RPC 通知。 */
|
||||||
|
private notify(method: string, params?: unknown) {
|
||||||
|
this.write(params === undefined ? { method } : { method, params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 JSON-RPC 消息写入 app-server 标准输入。 */
|
||||||
|
private write(value: unknown) {
|
||||||
|
const method = String(field(value, "method") || "");
|
||||||
|
const params = field(value, "params");
|
||||||
|
if (method) logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
|
||||||
|
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按行解析 app-server 标准输出。 */
|
||||||
|
private read(chunk: string) {
|
||||||
|
this.buffer += chunk;
|
||||||
|
const lines = this.buffer.split(/\r?\n/);
|
||||||
|
this.buffer = lines.pop() || "";
|
||||||
|
lines.filter(Boolean).forEach((line) => {
|
||||||
|
try {
|
||||||
|
this.handle(JSON.parse(line) as JsonRecord);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn("Invalid Codex app-server output", { error, line });
|
||||||
|
this.emit("agent_log", { text: line });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分派单条 JSON-RPC 响应、请求或通知。 */
|
||||||
|
private handle(message: JsonRecord) {
|
||||||
|
const id = Number(message.id);
|
||||||
|
if (message.error && this.pending.has(id)) {
|
||||||
|
const error = String(field(message.error, "message") || "Codex request failed");
|
||||||
|
if (/not materialized yet.*includeTurns/i.test(error)) logger.debug("Codex thread has no messages yet", { id });
|
||||||
|
else logger.warn("Codex request failed", { id, error });
|
||||||
|
return this.reject(id, error);
|
||||||
|
}
|
||||||
|
if (this.pending.has(id)) return this.resolve(id, message.result);
|
||||||
|
if (typeof message.method === "string" && "id" in message) return this.answerServerRequest(message);
|
||||||
|
if (typeof message.method === "string") this.handleNotification(message.method, (message.params || {}) as JsonRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 转换并广播 app-server 通知。 */
|
||||||
|
private handleNotification(method: string, params: JsonRecord) {
|
||||||
|
if (method === "serverRequest/resolved") {
|
||||||
|
const requestId = String(field(params, "requestId") || "");
|
||||||
|
if (requestId) this.approvalRequests.delete(requestId);
|
||||||
|
this.emit("codex_approval_resolved", { requestId, ...params });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId };
|
||||||
|
if (method === "item/agentMessage/delta") {
|
||||||
|
const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">;
|
||||||
|
this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
|
||||||
|
return this.emitDelta("agent_message", value);
|
||||||
|
}
|
||||||
|
if (method === "item/plan/delta") return this.emitDelta("plan", params as unknown as CodexNotificationParams<"item/plan/delta">);
|
||||||
|
if (method === "item/reasoning/summaryTextDelta") return this.emitDelta("reasoning", params as unknown as CodexNotificationParams<"item/reasoning/summaryTextDelta">);
|
||||||
|
if (method === "item/commandExecution/outputDelta") return this.emitDelta("command_execution", params as unknown as CodexNotificationParams<"item/commandExecution/outputDelta">);
|
||||||
|
if (method === "turn/plan/updated") {
|
||||||
|
const value = params as unknown as CodexNotificationParams<"turn/plan/updated">;
|
||||||
|
const update: CodexPlanUpdate = { ...value, threadId: value.threadId || "" };
|
||||||
|
if (update.threadId && update.turnId) this.plansByTurn.set(update.turnId, update);
|
||||||
|
params = update as unknown as JsonRecord;
|
||||||
|
}
|
||||||
|
if (method === "thread/tokenUsage/updated") {
|
||||||
|
this.lastUsage = normalizeUsage(params as unknown as CodexNotificationParams<"thread/tokenUsage/updated">);
|
||||||
|
this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = normalizeCodexNotification(method, params);
|
||||||
|
if (!event) return;
|
||||||
|
if (event.type === "item.completed") {
|
||||||
|
const item = field(event, "item") as JsonRecord | undefined;
|
||||||
|
const id = String(field(item, "id") || "");
|
||||||
|
this.flushDelta(id);
|
||||||
|
const streamedText = this.textByItem.get(id);
|
||||||
|
if (item?.type === "agent_message" && streamedText && !item.text) item.text = streamedText;
|
||||||
|
if (id) this.textByItem.delete(id);
|
||||||
|
}
|
||||||
|
if (event.type === "turn.completed") {
|
||||||
|
const turn = field(params, "turn");
|
||||||
|
const turnId = String(field(turn, "id") || field(params, "turnId") || "");
|
||||||
|
const plan = this.plansByTurn.get(turnId);
|
||||||
|
if (plan) this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
|
||||||
|
}
|
||||||
|
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
||||||
|
this.emit("agent_event", { agent: "codex", ...event });
|
||||||
|
if (event.type === "turn.completed") {
|
||||||
|
const turn = (params as unknown as CodexNotificationParams<"turn/completed">).turn;
|
||||||
|
const turnId = turn.id;
|
||||||
|
const pending = this.activeTurns.get(turnId);
|
||||||
|
const error = turn.error;
|
||||||
|
if (pending) {
|
||||||
|
this.activeTurns.delete(turnId);
|
||||||
|
error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
|
||||||
|
} else if (turnId) {
|
||||||
|
this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
|
||||||
|
}
|
||||||
|
if (turnId === this.currentTurnId) {
|
||||||
|
this.currentThreadId = "";
|
||||||
|
this.currentTurnId = "";
|
||||||
|
}
|
||||||
|
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并并广播 Agent 文本或执行输出增量。 */
|
||||||
|
private emitDelta(itemType: string, params: ItemDeltaParams) {
|
||||||
|
const id = params.itemId;
|
||||||
|
const pending = this.pendingDeltas.get(id);
|
||||||
|
if (pending) {
|
||||||
|
pending.delta += params.delta;
|
||||||
|
pending.itemType = itemType;
|
||||||
|
pending.params = params;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.pendingDeltas.set(id, {
|
||||||
|
delta: params.delta,
|
||||||
|
itemType,
|
||||||
|
params,
|
||||||
|
timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
|
||||||
|
private flushDelta(id: string) {
|
||||||
|
const pending = this.pendingDeltas.get(id);
|
||||||
|
if (!pending) return;
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
this.pendingDeltas.delete(id);
|
||||||
|
if (pending.delta) this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params as unknown as JsonRecord) });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 自动回复 app-server 发起的授权或交互请求。 */
|
||||||
|
private answerServerRequest(message: JsonRecord) {
|
||||||
|
const method = String(message.method);
|
||||||
|
const params = (field(message, "params") as JsonRecord) || {};
|
||||||
|
if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) {
|
||||||
|
const requestId = String(message.id);
|
||||||
|
this.approvalRequests.set(requestId, { id: Number(message.id), method, params });
|
||||||
|
this.emit("codex_approval", { requestId, method, ...params });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
||||||
|
this.write({ id: message.id, result });
|
||||||
|
this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 完成指定 JSON-RPC 请求。 */
|
||||||
|
private resolve(id: number, result: unknown) {
|
||||||
|
const pending = this.pending.get(id);
|
||||||
|
if (pending) (this.pending.delete(id), pending.resolve(result));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝指定 JSON-RPC 请求。 */
|
||||||
|
private reject(id: number, message: string) {
|
||||||
|
const pending = this.pending.get(id);
|
||||||
|
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拒绝进程退出时仍未完成的请求与 turn。 */
|
||||||
|
private failAll(message: string) {
|
||||||
|
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
||||||
|
this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
|
||||||
|
this.pending.clear();
|
||||||
|
this.activeTurns.clear();
|
||||||
|
this.pendingDeltas.clear();
|
||||||
|
this.textByItem.clear();
|
||||||
|
this.approvalRequests.clear();
|
||||||
|
this.currentThreadId = "";
|
||||||
|
this.currentTurnId = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
|
||||||
|
function canvasAgentMcpCommand() {
|
||||||
|
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
||||||
|
const entry = path.resolve(current || fileURLToPath(new URL("../index.js", import.meta.url)));
|
||||||
|
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
||||||
|
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 Codex app-server 使用的 MCP 配置。 */
|
||||||
|
function codexConfig(permissionMode: AgentPermissionMode) {
|
||||||
|
return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function threadSettings(permissionMode: AgentPermissionMode) {
|
||||||
|
return { approvalPolicy: permissionMode === "full" ? "never" as const : "on-request" as const, sandbox: permissionMode === "full" ? "danger-full-access" as const : "workspace-write" as const, config: codexConfig(permissionMode) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function turnSettings(permissionMode: AgentPermissionMode) {
|
||||||
|
return {
|
||||||
|
approvalPolicy: permissionMode === "full" ? "never" as const : "on-request" as const,
|
||||||
|
sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" as const } : { type: "workspaceWrite" as const, networkAccess: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将文本和本地图片转换为 Codex turn 输入。 */
|
||||||
|
function codexInput(prompt: string, images: string[]): CodexTurnInput[] {
|
||||||
|
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map<CodexTurnInput>((file) => ({ type: "localImage", path: file }))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 app-server 通知转换为前端使用的 Agent 事件。 */
|
||||||
|
function normalizeCodexNotification(method: string, params: JsonRecord): AgentEvent | null {
|
||||||
|
const scope = codexEventScope(params);
|
||||||
|
if (method === "thread/started") return { type: "thread.started", ...scope };
|
||||||
|
if (method === "turn/started") return { type: "turn.started", ...scope };
|
||||||
|
if (method === "turn/completed") return { type: "turn.completed", status: field(field(params, "turn"), "status"), error: field(field(params, "turn"), "error"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
|
||||||
|
if (method === "turn/plan/updated") return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope };
|
||||||
|
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
||||||
|
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
||||||
|
if (method === "error") return { type: "error", message: field(field(params, "error"), "message"), ...scope };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取 Codex 事件所属的线程和 turn。 */
|
||||||
|
function codexEventScope(params: JsonRecord) {
|
||||||
|
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
||||||
|
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||||
|
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一 app-server item 的类型和参数格式。 */
|
||||||
|
function normalizeItem(item: unknown) {
|
||||||
|
const value = item && typeof item === "object" ? { ...(item as JsonRecord) } : {};
|
||||||
|
if (value.type === "agentMessage") value.type = "agent_message";
|
||||||
|
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
||||||
|
if (value.type === "commandExecution") value.type = "command_execution";
|
||||||
|
if (value.type === "fileChange") value.type = "file_change";
|
||||||
|
if (value.type === "dynamicToolCall") value.type = "dynamic_tool_call";
|
||||||
|
if (value.type === "collabToolCall") value.type = "collab_tool_call";
|
||||||
|
if (value.type === "webSearch") value.type = "web_search";
|
||||||
|
if (value.type === "imageView") value.type = "image_view";
|
||||||
|
if (value.type === "imageGeneration") value.type = "image_generation";
|
||||||
|
if (value.type === "contextCompaction") value.type = "context_compaction";
|
||||||
|
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
||||||
|
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 Codex token usage 转换为前端字段。 */
|
||||||
|
function normalizeUsage(params: CodexNotificationParams<"thread/tokenUsage/updated">) {
|
||||||
|
const last = params.tokenUsage.last;
|
||||||
|
return {
|
||||||
|
input_tokens: last.inputTokens,
|
||||||
|
cached_input_tokens: last.cachedInputTokens,
|
||||||
|
output_tokens: last.outputTokens,
|
||||||
|
reasoning_output_tokens: last.reasoningOutputTokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 尝试将字符串解析为 JSON,失败时保留原值。 */
|
||||||
|
function parseMaybeJson(value: unknown) {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 定位当前依赖中 Codex CLI 的执行文件。 */
|
||||||
|
function codexBin() {
|
||||||
|
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { field } from "../utils/value.js";
|
||||||
|
import type { CodexPlanUpdate } from "./codex-protocol.js";
|
||||||
|
|
||||||
|
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||||
|
|
||||||
|
/** 将 Codex 线程转换为列表展示所需的摘要。 */
|
||||||
|
export function summarizeCodexThread(thread: unknown) {
|
||||||
|
return {
|
||||||
|
id: String(field(thread, "id") || ""),
|
||||||
|
sessionId: String(field(thread, "sessionId") || ""),
|
||||||
|
preview: displayUserText(String(field(thread, "preview") || "")),
|
||||||
|
name: stringOrNull(field(thread, "name")),
|
||||||
|
cwd: String(field(thread, "cwd") || ""),
|
||||||
|
status: String(field(thread, "status") || ""),
|
||||||
|
source: field(thread, "source"),
|
||||||
|
threadSource: field(thread, "threadSource"),
|
||||||
|
createdAt: Number(field(thread, "createdAt") || 0),
|
||||||
|
updatedAt: Number(field(thread, "updatedAt") || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 Codex turn items 转换为网页聊天历史。 */
|
||||||
|
export function threadMessages(thread: unknown, planUpdates: CodexPlanUpdate[] = []): AgentHistoryMessage[] {
|
||||||
|
const turns = arrayValue(field(thread, "turns"));
|
||||||
|
const plansByTurn = new Map(planUpdates.map((item) => [item.turnId, item]));
|
||||||
|
const messages: AgentHistoryMessage[] = [];
|
||||||
|
turns.forEach((turn, turnIndex) => {
|
||||||
|
const turnId = String(field(turn, "id") || turnIndex);
|
||||||
|
const turnError = String(field(field(turn, "error"), "message") || "").trim();
|
||||||
|
const planMessage = structuredPlanMessage(plansByTurn.get(turnId) || { threadId: "", turnId, explanation: stringOrNull(field(turn, "explanation")), plan: arrayValue(field(turn, "plan")) as CodexPlanUpdate["plan"], turnStatus: String(field(turn, "status") || "") });
|
||||||
|
let planAdded = false;
|
||||||
|
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
||||||
|
const type = String(field(item, "type") || "");
|
||||||
|
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
||||||
|
if (type === "userMessage") {
|
||||||
|
const text = displayUserText(userInputText(field(item, "content")));
|
||||||
|
if (text) messages.push({ id, role: "user", text });
|
||||||
|
if (planMessage && !planAdded) {
|
||||||
|
messages.push(planMessage);
|
||||||
|
planAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (type === "agentMessage") {
|
||||||
|
const text = String(field(item, "text") || "").trim();
|
||||||
|
if (text) messages.push({ id, role: "assistant", title: "Codex", text });
|
||||||
|
}
|
||||||
|
if (type === "mcpToolCall") {
|
||||||
|
const tool = String(field(item, "tool") || "工具调用");
|
||||||
|
const error = String(field(field(item, "error"), "message") || "");
|
||||||
|
const input = toolArguments(field(item, "arguments"));
|
||||||
|
messages.push({ id, role: "tool", title: toolName(tool), text: error || toolHistorySummary(tool, input), detail: toolHistoryDetail(tool, item, input, error) });
|
||||||
|
}
|
||||||
|
if (type === "commandExecution") {
|
||||||
|
const command = String(field(item, "command") || "").trim();
|
||||||
|
if (command) messages.push({ id, role: "tool", title: "执行命令", text: command, detail: commandDetail(item) });
|
||||||
|
}
|
||||||
|
if (type === "fileChange") {
|
||||||
|
const changes = arrayValue(field(item, "changes"));
|
||||||
|
messages.push({ id, role: "tool", title: "修改文件", text: fileChangeSummary(changes), detail: { kind: "file", status: field(item, "status"), files: changes.map((change) => ({ path: String(field(change, "path") || "未知文件"), action: changeKind(field(change, "kind")) })) } });
|
||||||
|
}
|
||||||
|
if (type === "reasoning") {
|
||||||
|
const text = readableText(field(item, "summary"));
|
||||||
|
if (text) messages.push({ id, role: "tool", title: "思考摘要", text, detail: { kind: "reasoning", status: "completed" } });
|
||||||
|
}
|
||||||
|
if (type === "plan") {
|
||||||
|
const text = String(field(item, "text") || "").trim();
|
||||||
|
if (text) messages.push({ id, role: "tool", title: "执行计划", text, detail: { kind: "plan", status: "completed" } });
|
||||||
|
}
|
||||||
|
if (type === "webSearch") messages.push({ id, role: "tool", title: "搜索资料", text: webSearchSummary(item), detail: { kind: "search", status: "completed", rows: webSearchRows(item) } });
|
||||||
|
if (type === "imageView") messages.push({ id, role: "tool", title: "查看图片", text: String(field(item, "path") || "已查看图片"), detail: { kind: "image", status: "completed" } });
|
||||||
|
if (type === "imageGeneration") messages.push({ id, role: "tool", title: "内置生图", text: String(field(item, "savedPath") || "图片生成完成"), detail: { kind: "image", status: field(item, "status"), savedPath: field(item, "savedPath") } });
|
||||||
|
if (type === "contextCompaction") messages.push({ id, role: "tool", title: "整理上下文", text: "已整理当前对话,继续处理任务", detail: { kind: "context", status: "completed" } });
|
||||||
|
if (type === "dynamicToolCall") messages.push({ id, role: "tool", title: "使用工具", text: "已完成工具操作", detail: { kind: "tool", status: field(item, "status") } });
|
||||||
|
if (type === "collabToolCall") messages.push({ id, role: "tool", title: "协作处理", text: "已完成协作任务", detail: { kind: "tool", status: field(item, "status") } });
|
||||||
|
});
|
||||||
|
if (planMessage && !planAdded) messages.push(planMessage);
|
||||||
|
if (turnError) {
|
||||||
|
const error = userFacingCodexError(turnError);
|
||||||
|
messages.push({ id: `error-${turnId}`, role: "error", title: error.title, text: error.text });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return messages.filter((item) => item.text).slice(-120);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将结构化任务计划转换为聊天进度卡片。 */
|
||||||
|
function structuredPlanMessage(update: CodexPlanUpdate): AgentHistoryMessage | null {
|
||||||
|
const tasks = arrayValue(update.plan).flatMap((item) => {
|
||||||
|
const step = String(field(item, "step") || "").trim();
|
||||||
|
return step ? [{ step, status: String(field(item, "status") || "pending") }] : [];
|
||||||
|
});
|
||||||
|
if (!tasks.length) return null;
|
||||||
|
const completed = tasks.filter((item) => item.status === "completed").length;
|
||||||
|
return {
|
||||||
|
id: `plan-${update.turnId}`,
|
||||||
|
role: "tool",
|
||||||
|
title: "任务进度",
|
||||||
|
text: `已完成 ${completed}/${tasks.length} 项`,
|
||||||
|
detail: { kind: "todo", status: planStatus(tasks, update.turnStatus), tasks, explanation: update.explanation || "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据步骤和 turn 状态生成任务卡片状态。 */
|
||||||
|
function planStatus(tasks: Array<{ status: string }>, turnStatus?: string) {
|
||||||
|
if (turnStatus === "failed") return "failed";
|
||||||
|
if (turnStatus === "interrupted") return "interrupted";
|
||||||
|
if (tasks.every((item) => item.status === "completed")) return "completed";
|
||||||
|
return turnStatus === "completed" ? "finished" : "inProgress";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将常见 Codex 错误转换为普通用户可理解的提示。 */
|
||||||
|
function userFacingCodexError(message: string) {
|
||||||
|
if (/selected model is at capacity/i.test(message)) return { title: "模型暂时繁忙", text: "当前选择的模型请求量过大,暂时无法处理。请稍后重试,或切换其他模型后再试。" };
|
||||||
|
return { title: "任务失败", text: message || "Codex 未能完成本次任务,请稍后重试。" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取用户输入条目中的文本与附件占位信息。 */
|
||||||
|
function userInputText(content: unknown) {
|
||||||
|
return arrayValue(content)
|
||||||
|
.map((item) => {
|
||||||
|
const type = String(field(item, "type") || "");
|
||||||
|
if (type === "text") return String(field(item, "text") || "");
|
||||||
|
if (type === "image" || type === "localImage") return "图片附件";
|
||||||
|
if (type === "mention") return `@${String(field(item, "name") || "文件")}`;
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除用户消息中由旧流程拼接的 Agent 前置提示词。 */
|
||||||
|
function displayUserText(text: string) {
|
||||||
|
const value = text.trim();
|
||||||
|
const marker = "用户请求:";
|
||||||
|
const index = value.lastIndexOf(marker);
|
||||||
|
const prompt = index >= 0 ? value.slice(index + marker.length) : value;
|
||||||
|
return prompt.split("\n\n本轮可用图片附件(顺序与图片输入一致):", 1)[0].trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将未知值转换为数组。 */
|
||||||
|
function arrayValue(value: unknown) {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将非空字符串保留为字符串,否则返回 null。 */
|
||||||
|
function stringOrNull(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成命令执行的用户可读详情。 */
|
||||||
|
function commandDetail(item: unknown) {
|
||||||
|
const rows = [
|
||||||
|
textRow("工作目录", field(item, "cwd")),
|
||||||
|
textRow("退出状态", field(item, "exitCode")),
|
||||||
|
durationRow(field(item, "durationMs")),
|
||||||
|
].filter(Boolean);
|
||||||
|
return { kind: "command", status: field(item, "status"), rows, output: String(field(item, "aggregatedOutput") || "").trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 MCP 工具的用户可读详情。 */
|
||||||
|
function toolHistoryDetail(tool: string, item: unknown, input: unknown, error: string) {
|
||||||
|
return { kind: "tool", status: error ? "failed" : field(item, "status"), rows: toolInputRows(tool, input), ...(error ? { output: error } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成 MCP 工具在对话中的结果摘要。 */
|
||||||
|
function toolHistorySummary(tool: string, input: unknown) {
|
||||||
|
if (tool === "site_navigate") return `已打开${routeName(String(field(input, "path") || "/"))}`;
|
||||||
|
if (tool === "canvas_list_projects") return "已读取画布列表";
|
||||||
|
if (tool === "canvas_get_state") return "已读取当前画布内容";
|
||||||
|
if (tool === "canvas_get_selection") return "已读取当前选中内容";
|
||||||
|
if (tool === "prompts_search") return `已搜索提示词“${String(field(input, "query") || "") || "全部"}”`;
|
||||||
|
if (tool === "assets_list") return "已读取我的素材";
|
||||||
|
if (tool === "generation_get_status") return "已检查生成任务状态";
|
||||||
|
return `${toolName(tool)}已完成`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 提取工具参数中适合普通用户查看的信息。 */
|
||||||
|
function toolInputRows(tool: string, input: unknown) {
|
||||||
|
if (tool === "site_navigate") return [textRow("目标页面", routeName(String(field(input, "path") || "/")))].filter(Boolean);
|
||||||
|
if (tool === "prompts_search") return [textRow("搜索内容", field(input, "query"))].filter(Boolean);
|
||||||
|
if (tool === "canvas_create_text_node") return [textRow("文本内容", field(input, "text"))].filter(Boolean);
|
||||||
|
if (tool === "canvas_apply_ops") return [textRow("操作数量", arrayValue(field(input, "ops")).length)].filter(Boolean);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成人类可读的文件变更摘要。 */
|
||||||
|
function fileChangeSummary(changes: unknown[]) {
|
||||||
|
if (!changes.length) return "已完成文件修改";
|
||||||
|
const names = changes.slice(0, 3).map((change) => String(field(change, "path") || "未知文件"));
|
||||||
|
if (changes.length === 1) return `${changeKind(field(changes[0], "kind"))}${names[0]}`;
|
||||||
|
return `涉及 ${changes.length} 个文件:${names.join("、")}${changes.length > names.length ? " 等" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成网页搜索摘要。 */
|
||||||
|
function webSearchSummary(item: unknown) {
|
||||||
|
const action = field(item, "action");
|
||||||
|
const type = String(field(action, "type") || "");
|
||||||
|
if (type === "openPage") return `打开网页:${String(field(action, "url") || "")}`;
|
||||||
|
if (type === "findInPage") return `在网页中查找“${String(field(action, "pattern") || "内容")}”`;
|
||||||
|
return `搜索:${String(field(item, "query") || field(action, "query") || "相关资料")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生成网页搜索详情行。 */
|
||||||
|
function webSearchRows(item: unknown) {
|
||||||
|
const action = field(item, "action");
|
||||||
|
return [textRow("关键词", field(item, "query") || field(action, "query")), textRow("网页", field(action, "url"))].filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 reasoning 结构中提取可读文本。 */
|
||||||
|
function readableText(value: unknown): string {
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (Array.isArray(value)) return value.map(readableText).filter(Boolean).join("\n");
|
||||||
|
return readableText(field(value, "text"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将历史工具参数解析为对象。 */
|
||||||
|
function toolArguments(value: unknown) {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as unknown;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建非空详情行。 */
|
||||||
|
function textRow(label: string, value: unknown) {
|
||||||
|
return value === undefined || value === null || value === "" ? null : { label, value: String(value) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建命令耗时详情行。 */
|
||||||
|
function durationRow(value: unknown) {
|
||||||
|
const duration = Number(value || 0);
|
||||||
|
return duration > 0 ? { label: "耗时", value: `${(duration / 1000).toFixed(1)} 秒` } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将文件变更类型转换为中文。 */
|
||||||
|
function changeKind(value: unknown) {
|
||||||
|
if (value === "add") return "新增";
|
||||||
|
if (value === "delete") return "删除";
|
||||||
|
return "修改";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将站点路由转换为中文页面名称。 */
|
||||||
|
function routeName(path: string) {
|
||||||
|
if (path === "/") return "首页";
|
||||||
|
if (path === "/canvas") return "画布页面";
|
||||||
|
if (path.startsWith("/canvas/")) return "指定画布";
|
||||||
|
if (path.startsWith("/image")) return "生图工作台";
|
||||||
|
if (path.startsWith("/video")) return "视频工作台";
|
||||||
|
if (path.startsWith("/prompts")) return "提示词中心";
|
||||||
|
if (path.startsWith("/assets")) return "我的素材";
|
||||||
|
if (path.startsWith("/config")) return "配置页面";
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将 MCP 工具名称转换为聊天记录中的中文标题。 */
|
||||||
|
function toolName(name: string) {
|
||||||
|
if (name === "site_navigate") return "打开页面";
|
||||||
|
if (name === "canvas_list_projects") return "查看画布列表";
|
||||||
|
if (name === "canvas_apply_ops") return "画布操作";
|
||||||
|
if (name === "canvas_get_state") return "读取画布";
|
||||||
|
if (name === "canvas_get_selection") return "读取选区";
|
||||||
|
if (name === "canvas_export_snapshot") return "导出快照";
|
||||||
|
if (name === "canvas_create_node") return "创建节点";
|
||||||
|
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||||
|
if (name === "canvas_create_text_node") return "创建文本";
|
||||||
|
if (name === "canvas_create_text_nodes") return "批量创建文本";
|
||||||
|
if (name === "canvas_create_config_node") return "创建生成配置";
|
||||||
|
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||||
|
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||||
|
if (name === "canvas_generate_text") return "生成文本";
|
||||||
|
if (name === "canvas_generate_image") return "生成图片";
|
||||||
|
if (name === "canvas_generate_video") return "生成视频";
|
||||||
|
if (name === "canvas_generate_audio") return "生成音频";
|
||||||
|
if (name === "canvas_update_node") return "更新节点";
|
||||||
|
if (name === "canvas_update_node_text") return "更新文本";
|
||||||
|
if (name === "canvas_move_nodes") return "移动节点";
|
||||||
|
if (name === "canvas_resize_node") return "调整节点尺寸";
|
||||||
|
if (name === "canvas_delete_nodes") return "删除节点";
|
||||||
|
if (name === "canvas_connect_nodes") return "连接节点";
|
||||||
|
if (name === "canvas_select_nodes") return "选择节点";
|
||||||
|
if (name === "canvas_set_viewport") return "调整视口";
|
||||||
|
if (name === "canvas_run_generation") return "触发生成";
|
||||||
|
if (name === "workbench_image_get_config") return "读取生图设置";
|
||||||
|
if (name === "workbench_image_generate") return "在生图工作台生成";
|
||||||
|
if (name === "workbench_video_get_config") return "读取视频设置";
|
||||||
|
if (name === "workbench_video_generate") return "在视频工作台生成";
|
||||||
|
if (name === "prompts_search") return "搜索提示词";
|
||||||
|
if (name === "assets_list") return "查看我的素材";
|
||||||
|
if (name === "assets_add") return "添加到我的素材";
|
||||||
|
if (name === "generation_get_status") return "查看生成状态";
|
||||||
|
return "工具操作";
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { JsonRecord } from "../utils/value.js";
|
||||||
|
|
||||||
|
export type CodexThread = JsonRecord & { id: string; cwd: string; turns?: CodexTurn[] };
|
||||||
|
export type CodexTurn = JsonRecord & { id: string; error?: CodexTurnError | null; durationMs?: number | null };
|
||||||
|
export type CodexTurnError = JsonRecord & { message: string };
|
||||||
|
export type CodexItem = JsonRecord & { id: string; type: string; text?: string };
|
||||||
|
export type CodexPlanStep = { step: string; status: "pending" | "inProgress" | "completed" };
|
||||||
|
export type CodexPlanUpdate = { threadId: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[]; turnStatus?: string };
|
||||||
|
|
||||||
|
export type CodexTurnInput =
|
||||||
|
| { type: "text"; text: string; text_elements: [] }
|
||||||
|
| { type: "localImage"; path: string };
|
||||||
|
|
||||||
|
type ThreadOptions = {
|
||||||
|
approvalPolicy: "never" | "on-request";
|
||||||
|
sandbox: "workspace-write" | "danger-full-access";
|
||||||
|
config: JsonRecord;
|
||||||
|
cwd?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodexRequestSpec = {
|
||||||
|
initialize: {
|
||||||
|
params: {
|
||||||
|
clientInfo: { name: string; title: string; version: string };
|
||||||
|
capabilities: { experimentalApi: boolean; requestAttestation: boolean };
|
||||||
|
};
|
||||||
|
result: JsonRecord;
|
||||||
|
};
|
||||||
|
"thread/start": {
|
||||||
|
params: ThreadOptions & { threadSource: "user" };
|
||||||
|
result: { thread: CodexThread };
|
||||||
|
};
|
||||||
|
"thread/resume": {
|
||||||
|
params: ThreadOptions & { threadId: string };
|
||||||
|
result: { thread: CodexThread };
|
||||||
|
};
|
||||||
|
"thread/list": {
|
||||||
|
params: {
|
||||||
|
limit: number;
|
||||||
|
sortKey: "updated_at";
|
||||||
|
sortDirection: "desc";
|
||||||
|
sourceKinds: Array<"cli" | "vscode" | "appServer" | "exec">;
|
||||||
|
cwd: string;
|
||||||
|
searchTerm?: string;
|
||||||
|
};
|
||||||
|
result: { data: CodexThread[]; nextCursor: string | null; backwardsCursor: string | null };
|
||||||
|
};
|
||||||
|
"thread/read": {
|
||||||
|
params: { threadId: string; includeTurns: boolean };
|
||||||
|
result: { thread: CodexThread };
|
||||||
|
};
|
||||||
|
"thread/archive": {
|
||||||
|
params: { threadId: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
"turn/start": {
|
||||||
|
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "on-request"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } };
|
||||||
|
result: { turn: CodexTurn };
|
||||||
|
};
|
||||||
|
"turn/interrupt": {
|
||||||
|
params: { threadId: string; turnId: string };
|
||||||
|
result: Record<string, never>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodexRequestMethod = keyof CodexRequestSpec;
|
||||||
|
export type CodexRequestParams<Method extends CodexRequestMethod> = CodexRequestSpec[Method]["params"];
|
||||||
|
export type CodexRequestResult<Method extends CodexRequestMethod> = CodexRequestSpec[Method]["result"];
|
||||||
|
|
||||||
|
type TokenUsageBreakdown = {
|
||||||
|
inputTokens: number;
|
||||||
|
cachedInputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
reasoningOutputTokens: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CodexNotificationSpec = {
|
||||||
|
"thread/started": { thread: CodexThread };
|
||||||
|
"turn/started": { threadId?: string; turn: CodexTurn };
|
||||||
|
"turn/completed": { threadId?: string; turn: CodexTurn };
|
||||||
|
"turn/plan/updated": { threadId?: string; turnId: string; explanation?: string | null; plan: CodexPlanStep[] };
|
||||||
|
"item/started": { threadId: string; turnId: string; item: CodexItem };
|
||||||
|
"item/completed": { threadId: string; turnId: string; item: CodexItem };
|
||||||
|
"item/agentMessage/delta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||||
|
"item/plan/delta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||||
|
"item/reasoning/summaryTextDelta": { threadId: string; turnId: string; itemId: string; delta: string; summaryIndex: number };
|
||||||
|
"item/commandExecution/outputDelta": { threadId: string; turnId: string; itemId: string; delta: string };
|
||||||
|
"thread/tokenUsage/updated": { threadId: string; turnId: string; tokenUsage: { last: TokenUsageBreakdown } };
|
||||||
|
error: { threadId: string; turnId: string; error: CodexTurnError; willRetry: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CodexNotificationMethod = keyof CodexNotificationSpec;
|
||||||
|
export type CodexNotificationParams<Method extends CodexNotificationMethod> = CodexNotificationSpec[Method];
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { errorMessage, field } from "../utils/value.js";
|
||||||
|
import { CodexAppClient } from "./codex-client.js";
|
||||||
|
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
|
||||||
|
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
|
||||||
|
|
||||||
|
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||||
|
|
||||||
|
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||||
|
let codexApp: CodexAppClient | null = null;
|
||||||
|
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||||
|
let codexThreadId = "";
|
||||||
|
const unmaterializedThreadIds = new Set<string>();
|
||||||
|
|
||||||
|
export { summarizeCodexThread } from "./codex-history.js";
|
||||||
|
|
||||||
|
/** 将 Codex turn 加入串行队列并等待执行完成。 */
|
||||||
|
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
||||||
|
if (!prompt.trim()) return;
|
||||||
|
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
||||||
|
await codexQueue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 中断当前线程正在执行的 Codex turn。 */
|
||||||
|
export async function interruptCodexTurn(threadId?: string) {
|
||||||
|
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
||||||
|
return await codexApp.interruptCurrentTurn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 回复当前 app-server 的待处理权限请求。 */
|
||||||
|
export async function resolveCodexApproval(requestId: string, decision: string) {
|
||||||
|
return Boolean(codexApp?.resolveApproval(requestId, decision));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建新的 Codex 线程并记录当前线程 ID。 */
|
||||||
|
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
const thread = await app.startThread(cwd, permissionMode);
|
||||||
|
codexThreadId = String(field(thread, "id") || "");
|
||||||
|
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复指定 Codex 线程并返回聊天历史。 */
|
||||||
|
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
await loadCodexThread(emit, threadId, cwd, false);
|
||||||
|
const thread = await app.resumeThread(threadId, cwd, permissionMode);
|
||||||
|
assertThreadWorkspace(thread, cwd);
|
||||||
|
codexThreadId = String(field(thread, "id") || threadId);
|
||||||
|
const historyThread = await loadCodexThread(emit, codexThreadId, cwd, true);
|
||||||
|
return { thread, messages: threadMessages(historyThread, app.planUpdates(threadId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询当前工作空间中的 Codex 线程。 */
|
||||||
|
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
const result = await app.listThreads({
|
||||||
|
limit: options.limit || 40,
|
||||||
|
sortKey: "updated_at",
|
||||||
|
sortDirection: "desc",
|
||||||
|
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||||||
|
cwd: options.cwd,
|
||||||
|
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||||||
|
});
|
||||||
|
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
||||||
|
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取指定 Codex 线程及其聊天历史。 */
|
||||||
|
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
let thread: unknown;
|
||||||
|
try {
|
||||||
|
thread = await loadCodexThread(emit, threadId, cwd, !unmaterializedThreadIds.has(threadId));
|
||||||
|
} catch (error) {
|
||||||
|
if (!/not materialized yet.*includeTurns/i.test(errorMessage(error))) throw error;
|
||||||
|
unmaterializedThreadIds.add(threadId);
|
||||||
|
thread = await loadCodexThread(emit, threadId, cwd, false);
|
||||||
|
}
|
||||||
|
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread, app.planUpdates(threadId)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认指定 Codex 线程属于当前工作空间。 */
|
||||||
|
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
||||||
|
await loadCodexThread(emit, threadId, cwd, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 归档指定 Codex 线程。 */
|
||||||
|
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
await loadCodexThread(emit, threadId, cwd, false);
|
||||||
|
await app.archiveThread(threadId);
|
||||||
|
app.clearPlanUpdates(threadId);
|
||||||
|
unmaterializedThreadIds.delete(threadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断线程异常是否允许自动新建线程后重试。 */
|
||||||
|
export function isRecoverableThreadError(error: unknown) {
|
||||||
|
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 执行一次 Codex turn,并负责附件临时文件和线程恢复。 */
|
||||||
|
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||||
|
let files: string[] = [];
|
||||||
|
try {
|
||||||
|
options.onStart?.();
|
||||||
|
files = await writeAttachmentFiles(attachments);
|
||||||
|
const app = await getCodexApp(options.appEmit || emit);
|
||||||
|
let threadId = await ensureCodexThread(app, options, emit);
|
||||||
|
options.onThread?.(threadId);
|
||||||
|
unmaterializedThreadIds.delete(threadId);
|
||||||
|
try {
|
||||||
|
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRecoverableThreadError(error)) throw error;
|
||||||
|
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||||
|
codexThreadId = "";
|
||||||
|
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||||
|
options.onThread?.(threadId);
|
||||||
|
unmaterializedThreadIds.delete(threadId);
|
||||||
|
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Codex turn failed", error);
|
||||||
|
emit("agent_error", { message: errorMessage(error) });
|
||||||
|
} finally {
|
||||||
|
options.onFinish?.();
|
||||||
|
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 恢复请求线程或创建新的 Codex 线程。 */
|
||||||
|
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
||||||
|
if (options.threadId) {
|
||||||
|
if (options.threadId === codexThreadId) return codexThreadId;
|
||||||
|
try {
|
||||||
|
const result = await app.readThread(options.threadId, false);
|
||||||
|
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
||||||
|
const thread = await app.resumeThread(options.threadId, options.cwd, options.permissionMode || "request");
|
||||||
|
assertThreadWorkspace(thread, options.cwd);
|
||||||
|
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||||
|
return codexThreadId;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isRecoverableThreadError(error)) throw error;
|
||||||
|
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!codexThreadId) {
|
||||||
|
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
|
||||||
|
codexThreadId = String(field(thread, "id") || "");
|
||||||
|
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
|
||||||
|
}
|
||||||
|
return codexThreadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 app-server 读取线程并校验工作空间。 */
|
||||||
|
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||||
|
const app = await getCodexApp(emit);
|
||||||
|
const result = await app.readThread(threadId, includeTurns);
|
||||||
|
const thread = field(result, "thread") || {};
|
||||||
|
assertThreadWorkspace(thread, cwd);
|
||||||
|
return thread;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取已启动的 Codex app-server 客户端。 */
|
||||||
|
async function getCodexApp(emit: AgentEmit) {
|
||||||
|
if (codexApp) return codexApp;
|
||||||
|
codexAppStart ||= CodexAppClient.start(emit, () => {
|
||||||
|
codexApp = null;
|
||||||
|
codexThreadId = "";
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
codexApp = await codexAppStart;
|
||||||
|
return codexApp;
|
||||||
|
} finally {
|
||||||
|
codexAppStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验线程是否属于指定工作空间。 */
|
||||||
|
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||||
|
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||||
|
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 判断线程工作目录是否与当前工作空间一致。 */
|
||||||
|
function threadInWorkspace(thread: unknown, cwd: string) {
|
||||||
|
const threadCwd = String(field(thread, "cwd") || "");
|
||||||
|
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将图片附件写入临时文件供 Codex 读取。 */
|
||||||
|
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
||||||
|
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将单个 Data URL 图片附件写入临时文件。 */
|
||||||
|
async function writeAttachmentFile(item: AgentAttachment) {
|
||||||
|
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
||||||
|
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
||||||
|
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
||||||
|
await fs.writeFile(file, Buffer.from(data, "base64"));
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据图片 MIME 类型返回临时文件扩展名。 */
|
||||||
|
function imageExt(type = "") {
|
||||||
|
if (type.includes("png")) return "png";
|
||||||
|
if (type.includes("webp")) return "webp";
|
||||||
|
return "jpg";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/** Agent 向网页广播事件的函数类型。 */
|
||||||
|
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||||
|
|
||||||
|
/** 用户随当前 Agent 消息上传的附件。 */
|
||||||
|
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
|
||||||
|
|
||||||
|
/** Codex 文件、命令和网络权限模式。 */
|
||||||
|
export type AgentPermissionMode = "request" | "automatic" | "full";
|
||||||
@@ -1,524 +0,0 @@
|
|||||||
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
|
||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
import { createRequire } from "node:module";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
import { AGENT_PROMPT, VERSION } from "./config.js";
|
|
||||||
import type { AgentAttachment, AgentEmit } from "./types.js";
|
|
||||||
|
|
||||||
type Json = Record<string, unknown>;
|
|
||||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
|
||||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
||||||
type CodexRunOptions = { threadId?: string; cwd?: string };
|
|
||||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
|
||||||
|
|
||||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
|
||||||
let codexApp: CodexAppClient | null = null;
|
|
||||||
let codexThreadId = "";
|
|
||||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
|
||||||
const require = createRequire(import.meta.url);
|
|
||||||
|
|
||||||
export function withAgentPrompt(prompt: string) {
|
|
||||||
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
|
||||||
if (!prompt.trim()) return;
|
|
||||||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
|
||||||
await codexQueue;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function interruptCodexTurn() {
|
|
||||||
if (!codexApp) return false;
|
|
||||||
return codexApp.interruptCurrentTurn();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
|
||||||
let files: string[] = [];
|
|
||||||
try {
|
|
||||||
files = await writeAttachmentFiles(attachments);
|
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
|
||||||
let threadId = await ensureCodexThread(codexApp, options, emit);
|
|
||||||
try {
|
|
||||||
await codexApp.startTurn(threadId, prompt, files);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isRecoverableThreadError(error)) throw error;
|
|
||||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
|
||||||
codexThreadId = "";
|
|
||||||
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit);
|
|
||||||
await codexApp.startTurn(threadId, prompt, files);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
emit("agent_error", { message: errorMessage(error) });
|
|
||||||
} finally {
|
|
||||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
|
||||||
const thread = await codexApp.startThread(cwd);
|
|
||||||
codexThreadId = String(field(thread, "id") || "");
|
|
||||||
return thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
|
||||||
await loadCodexThread(emit, threadId, cwd, false);
|
|
||||||
const thread = await codexApp.resumeThread(threadId, cwd);
|
|
||||||
assertThreadWorkspace(thread, cwd);
|
|
||||||
codexThreadId = String(field(thread, "id") || threadId);
|
|
||||||
return { thread, messages: threadMessages(thread) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
|
||||||
const result = await codexApp.listThreads({
|
|
||||||
limit: options.limit || 40,
|
|
||||||
sortKey: "updated_at",
|
|
||||||
sortDirection: "desc",
|
|
||||||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
|
||||||
cwd: options.cwd,
|
|
||||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
|
||||||
});
|
|
||||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
|
||||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
|
||||||
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
|
||||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
|
||||||
await loadCodexThread(emit, threadId, cwd, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
|
||||||
await loadCodexThread(emit, threadId, cwd, false);
|
|
||||||
await codexApp.archiveThread(threadId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
|
||||||
if (!prompt.trim()) return;
|
|
||||||
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", prompt], ["ignore", "pipe", "pipe"], emit);
|
|
||||||
if (!child) return;
|
|
||||||
pipeJsonLines(child, emit, "claude");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
|
||||||
if (options.threadId) {
|
|
||||||
if (options.threadId === codexThreadId) return codexThreadId;
|
|
||||||
try {
|
|
||||||
const result = await app.readThread(options.threadId, false);
|
|
||||||
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
|
||||||
const thread = await app.resumeThread(options.threadId, options.cwd);
|
|
||||||
assertThreadWorkspace(thread, options.cwd);
|
|
||||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
|
||||||
return codexThreadId;
|
|
||||||
} catch (error) {
|
|
||||||
if (!isRecoverableThreadError(error)) throw error;
|
|
||||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!codexThreadId) {
|
|
||||||
const thread = await app.startThread(options.cwd);
|
|
||||||
codexThreadId = String(field(thread, "id") || "");
|
|
||||||
}
|
|
||||||
return codexThreadId;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecoverableThreadError(error: unknown) {
|
|
||||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
class CodexAppClient {
|
|
||||||
private nextId = 1;
|
|
||||||
private buffer = "";
|
|
||||||
private textByItem = new Map<string, string>();
|
|
||||||
private deltaCount = 0;
|
|
||||||
private lastUsage: unknown = null;
|
|
||||||
private pending = new Map<number, PendingRequest>();
|
|
||||||
private activeTurns = new Map<string, PendingRequest>();
|
|
||||||
private completedTurns = new Map<string, Error | null>();
|
|
||||||
|
|
||||||
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
|
||||||
|
|
||||||
static async start(emit: AgentEmit) {
|
|
||||||
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
||||||
const client = new CodexAppClient(child, emit);
|
|
||||||
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
|
||||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
|
||||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
|
||||||
child.on("exit", (code) => {
|
|
||||||
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
|
||||||
codexApp = null;
|
|
||||||
codexThreadId = "";
|
|
||||||
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
|
||||||
});
|
|
||||||
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
|
||||||
client.notify("initialized");
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
async startThread(cwd?: string) {
|
|
||||||
const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
|
||||||
const thread = field(result, "thread") as Json | undefined;
|
|
||||||
const id = String(field(thread, "id") || "");
|
|
||||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
|
||||||
return thread || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
async resumeThread(threadId: string, cwd?: string) {
|
|
||||||
const result = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) });
|
|
||||||
const thread = field(result, "thread") as Json | undefined;
|
|
||||||
const id = String(field(thread, "id") || "");
|
|
||||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
|
||||||
return thread || {};
|
|
||||||
}
|
|
||||||
|
|
||||||
listThreads(params: Json) {
|
|
||||||
return this.request("thread/list", params);
|
|
||||||
}
|
|
||||||
|
|
||||||
readThread(threadId: string, includeTurns = true) {
|
|
||||||
return this.request("thread/read", { threadId, includeTurns });
|
|
||||||
}
|
|
||||||
|
|
||||||
archiveThread(threadId: string) {
|
|
||||||
return this.request("thread/archive", { threadId });
|
|
||||||
}
|
|
||||||
|
|
||||||
async startTurn(threadId: string, prompt: string, images: string[]) {
|
|
||||||
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
|
||||||
const turnId = String(field(field(result, "turn"), "id") || "");
|
|
||||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
|
||||||
const completed = this.completedTurns.get(turnId);
|
|
||||||
if (this.completedTurns.has(turnId)) {
|
|
||||||
this.completedTurns.delete(turnId);
|
|
||||||
if (completed) throw completed;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
|
||||||
}
|
|
||||||
|
|
||||||
interruptCurrentTurn() {
|
|
||||||
if (this.activeTurns.size === 0) return false;
|
|
||||||
try {
|
|
||||||
this.child.kill("SIGINT");
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private request(method: string, params: unknown) {
|
|
||||||
const id = this.nextId++;
|
|
||||||
this.write({ id, method, params });
|
|
||||||
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
||||||
}
|
|
||||||
|
|
||||||
private notify(method: string, params?: unknown) {
|
|
||||||
this.write(params === undefined ? { method } : { method, params });
|
|
||||||
}
|
|
||||||
|
|
||||||
private write(value: unknown) {
|
|
||||||
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private read(chunk: string) {
|
|
||||||
this.buffer += chunk;
|
|
||||||
const lines = this.buffer.split(/\r?\n/);
|
|
||||||
this.buffer = lines.pop() || "";
|
|
||||||
lines.filter(Boolean).forEach((line) => {
|
|
||||||
try {
|
|
||||||
this.handle(JSON.parse(line) as Json);
|
|
||||||
} catch {
|
|
||||||
this.emit("agent_log", { text: line });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private handle(message: Json) {
|
|
||||||
const id = Number(message.id);
|
|
||||||
if (message.error && this.pending.has(id)) return this.reject(id, String(field(message.error, "message") || "Codex request failed"));
|
|
||||||
if (this.pending.has(id)) return this.resolve(id, message.result);
|
|
||||||
if (typeof message.method === "string" && "id" in message) return this.answerServerRequest(message);
|
|
||||||
if (typeof message.method === "string") this.handleNotification(message.method, (message.params || {}) as Json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleNotification(method: string, params: Json) {
|
|
||||||
if (method === "item/agentMessage/delta") return this.emitDelta(params);
|
|
||||||
if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params);
|
|
||||||
const event = normalizeCodexNotification(method, params);
|
|
||||||
if (!event) return;
|
|
||||||
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
|
||||||
this.emit("agent_event", { agent: "codex", ...event });
|
|
||||||
if (event.type === "turn.completed") {
|
|
||||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
|
||||||
const pending = this.activeTurns.get(turnId);
|
|
||||||
const error = field(field(params, "turn"), "error");
|
|
||||||
if (pending) {
|
|
||||||
this.activeTurns.delete(turnId);
|
|
||||||
error ? pending.reject(new Error(String(field(error, "message") || "Codex turn failed"))) : pending.resolve(event);
|
|
||||||
} else if (turnId) {
|
|
||||||
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
|
||||||
}
|
|
||||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount });
|
|
||||||
this.deltaCount = 0;
|
|
||||||
this.emit("agent_done", { agent: "codex", usage: event.usage });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emitDelta(params: Json) {
|
|
||||||
const id = String(field(params, "itemId") || "");
|
|
||||||
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
|
||||||
this.deltaCount += 1;
|
|
||||||
this.textByItem.set(id, text);
|
|
||||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } });
|
|
||||||
}
|
|
||||||
|
|
||||||
private answerServerRequest(message: Json) {
|
|
||||||
const method = String(message.method);
|
|
||||||
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
|
||||||
this.write({ id: message.id, result });
|
|
||||||
this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result });
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolve(id: number, result: unknown) {
|
|
||||||
const pending = this.pending.get(id);
|
|
||||||
if (pending) (this.pending.delete(id), pending.resolve(result));
|
|
||||||
}
|
|
||||||
|
|
||||||
private reject(id: number, message: string) {
|
|
||||||
const pending = this.pending.get(id);
|
|
||||||
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
|
|
||||||
}
|
|
||||||
|
|
||||||
failAll(message: string) {
|
|
||||||
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
|
||||||
this.pending.clear();
|
|
||||||
this.activeTurns.clear();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasAgentMcpCommand() {
|
|
||||||
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
|
||||||
const entry = path.resolve(current || fileURLToPath(new URL("./index.js", import.meta.url)));
|
|
||||||
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
|
||||||
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function codexConfig() {
|
|
||||||
return { mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function codexInput(prompt: string, images: string[]) {
|
|
||||||
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
|
||||||
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
|
||||||
if (method === "turn/started") return { type: "turn.started" };
|
|
||||||
if (method === "turn/completed") return { type: "turn.completed", usage: null };
|
|
||||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
|
||||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
|
||||||
if (method === "error") return { type: "error", message: field(params, "message") };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
|
||||||
codexApp ||= await CodexAppClient.start(emit);
|
|
||||||
const result = await codexApp.readThread(threadId, includeTurns);
|
|
||||||
const thread = field(result, "thread") || {};
|
|
||||||
assertThreadWorkspace(thread, cwd);
|
|
||||||
return thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
|
||||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
|
||||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
|
||||||
}
|
|
||||||
|
|
||||||
function threadInWorkspace(thread: unknown, cwd: string) {
|
|
||||||
const threadCwd = String(field(thread, "cwd") || "");
|
|
||||||
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeItem(item: unknown) {
|
|
||||||
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
|
||||||
if (value.type === "agentMessage") value.type = "agent_message";
|
|
||||||
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
|
||||||
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
|
||||||
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeUsage(params: Json) {
|
|
||||||
const total = field(field(params, "tokenUsage"), "total") as Json | undefined;
|
|
||||||
return {
|
|
||||||
input_tokens: field(total, "inputTokens"),
|
|
||||||
cached_input_tokens: field(total, "cachedInputTokens"),
|
|
||||||
output_tokens: field(total, "outputTokens"),
|
|
||||||
reasoning_output_tokens: field(total, "reasoningOutputTokens"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMaybeJson(value: unknown) {
|
|
||||||
if (typeof value !== "string") return value;
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function field(value: unknown, key: string) {
|
|
||||||
return value && typeof value === "object" ? (value as Json)[key] : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeCodexThread(thread: unknown) {
|
|
||||||
return {
|
|
||||||
id: String(field(thread, "id") || ""),
|
|
||||||
sessionId: String(field(thread, "sessionId") || ""),
|
|
||||||
preview: displayUserText(String(field(thread, "preview") || "")),
|
|
||||||
name: stringOrNull(field(thread, "name")),
|
|
||||||
cwd: String(field(thread, "cwd") || ""),
|
|
||||||
status: String(field(thread, "status") || ""),
|
|
||||||
source: field(thread, "source"),
|
|
||||||
threadSource: field(thread, "threadSource"),
|
|
||||||
createdAt: Number(field(thread, "createdAt") || 0),
|
|
||||||
updatedAt: Number(field(thread, "updatedAt") || 0),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function threadMessages(thread: unknown): AgentHistoryMessage[] {
|
|
||||||
const turns = arrayValue(field(thread, "turns"));
|
|
||||||
const messages: AgentHistoryMessage[] = [];
|
|
||||||
turns.forEach((turn, turnIndex) => {
|
|
||||||
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
|
||||||
const type = String(field(item, "type") || "");
|
|
||||||
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
|
||||||
if (type === "userMessage") {
|
|
||||||
const text = displayUserText(userInputText(field(item, "content")));
|
|
||||||
if (text) messages.push({ id, role: "user", text });
|
|
||||||
}
|
|
||||||
if (type === "agentMessage") {
|
|
||||||
const text = String(field(item, "text") || "").trim();
|
|
||||||
if (text) messages.push({ id, role: "assistant", title: "Codex", text, streamId: id });
|
|
||||||
}
|
|
||||||
if (type === "mcpToolCall") {
|
|
||||||
const tool = String(field(item, "tool") || "工具调用");
|
|
||||||
const error = field(field(item, "error"), "message");
|
|
||||||
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
|
|
||||||
}
|
|
||||||
if (type === "commandExecution") {
|
|
||||||
const command = String(field(item, "command") || "").trim();
|
|
||||||
if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
|
|
||||||
}
|
|
||||||
if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return messages.filter((item) => item.text).slice(-120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function userInputText(content: unknown) {
|
|
||||||
return arrayValue(content)
|
|
||||||
.map((item) => {
|
|
||||||
const type = String(field(item, "type") || "");
|
|
||||||
if (type === "text") return String(field(item, "text") || "");
|
|
||||||
if (type === "image" || type === "localImage") return "图片附件";
|
|
||||||
if (type === "mention") return `@${String(field(item, "name") || "文件")}`;
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function displayUserText(text: string) {
|
|
||||||
const value = text.trim();
|
|
||||||
const marker = "用户请求:";
|
|
||||||
const index = value.lastIndexOf(marker);
|
|
||||||
return (index >= 0 ? value.slice(index + marker.length) : value).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function arrayValue(value: unknown) {
|
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringOrNull(value: unknown) {
|
|
||||||
return typeof value === "string" && value.trim() ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toolName(name: string) {
|
|
||||||
if (name === "canvas_apply_ops") return "画布操作";
|
|
||||||
if (name === "canvas_get_state") return "读取画布";
|
|
||||||
if (name === "canvas_get_selection") return "读取选区";
|
|
||||||
if (name === "canvas_export_snapshot") return "导出快照";
|
|
||||||
if (name === "canvas_create_text_node") return "创建文本";
|
|
||||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
|
||||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
|
||||||
if (name === "canvas_generate_text") return "生成文本";
|
|
||||||
if (name === "canvas_generate_image") return "生成图片";
|
|
||||||
if (name === "canvas_generate_video") return "生成视频";
|
|
||||||
if (name === "canvas_generate_audio") return "生成音频";
|
|
||||||
if (name === "canvas_run_generation") return "触发生成";
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
|
||||||
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function writeAttachmentFile(item: AgentAttachment) {
|
|
||||||
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
|
||||||
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
|
||||||
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
|
||||||
await fs.writeFile(file, Buffer.from(data, "base64"));
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
function imageExt(type = "") {
|
|
||||||
if (type.includes("png")) return "png";
|
|
||||||
if (type.includes("webp")) return "webp";
|
|
||||||
return "jpg";
|
|
||||||
}
|
|
||||||
|
|
||||||
function codexBin() {
|
|
||||||
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
|
||||||
}
|
|
||||||
|
|
||||||
function pipeJsonLines(child: ReturnType<typeof spawn>, emit: AgentEmit, agent: string) {
|
|
||||||
let out = "";
|
|
||||||
child.stdout?.on("data", (chunk) => {
|
|
||||||
out += chunk.toString();
|
|
||||||
const lines = out.split(/\r?\n/);
|
|
||||||
out = lines.pop() || "";
|
|
||||||
lines.filter(Boolean).forEach((line) => {
|
|
||||||
try {
|
|
||||||
emit("agent_event", { agent, ...JSON.parse(line) });
|
|
||||||
} catch {
|
|
||||||
emit("agent_event", { agent, type: "raw", text: line });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
|
||||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
|
||||||
child.on("close", (code) => emit("agent_done", { agent, code }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function spawnAgent(name: string, args: string[], stdio: StdioOptions, emit: AgentEmit) {
|
|
||||||
try {
|
|
||||||
return spawn(name, args, { stdio, shell: process.platform === "win32", windowsHide: true });
|
|
||||||
} catch (error) {
|
|
||||||
emit("agent_error", { message: errorMessage(error) });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(error: unknown) {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import type { ServerResponse } from "node:http";
|
|
||||||
|
|
||||||
import { type ToolName } from "./schemas.js";
|
|
||||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
|
||||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
|
||||||
|
|
||||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
||||||
|
|
||||||
const SITE_TOOLS = new Set<ToolName>([
|
|
||||||
"site_navigate",
|
|
||||||
"canvas_list_projects",
|
|
||||||
"workbench_image_get_config",
|
|
||||||
"workbench_image_generate",
|
|
||||||
"workbench_video_get_config",
|
|
||||||
"workbench_video_generate",
|
|
||||||
"prompts_search",
|
|
||||||
"assets_list",
|
|
||||||
"assets_add",
|
|
||||||
]);
|
|
||||||
|
|
||||||
export class CanvasSession {
|
|
||||||
private clients = new Map<string, ServerResponse>();
|
|
||||||
private pending = new Map<string, PendingRequest>();
|
|
||||||
private canvasState: CanvasSnapshot | null = null;
|
|
||||||
|
|
||||||
health() {
|
|
||||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size };
|
|
||||||
}
|
|
||||||
|
|
||||||
openEvents(url: URL, res: ServerResponse) {
|
|
||||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
|
||||||
const statusOnly = url.searchParams.get("role") === "status";
|
|
||||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
|
||||||
if (!statusOnly) this.clients.set(clientId, res);
|
|
||||||
sendEvent(res, "hello", { ok: true, clientId });
|
|
||||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
|
||||||
res.on("close", () => {
|
|
||||||
clearInterval(timer);
|
|
||||||
if (!statusOnly) this.clients.delete(clientId);
|
|
||||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
updateState(body: unknown, clientId?: string) {
|
|
||||||
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
|
|
||||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
|
||||||
if (!item || !body.requestId) return;
|
|
||||||
this.pending.delete(body.requestId);
|
|
||||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
|
||||||
}
|
|
||||||
|
|
||||||
emitAll(type: string, payload: unknown) {
|
|
||||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
|
||||||
}
|
|
||||||
|
|
||||||
async callTool(name: unknown, rawInput: unknown) {
|
|
||||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
|
||||||
let tool: ToolName = name;
|
|
||||||
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
|
|
||||||
if (SITE_TOOLS.has(tool)) {
|
|
||||||
if (!this.clients.size) throw new Error("当前没有已连接网页");
|
|
||||||
return await this.requestCanvasTool(tool, input);
|
|
||||||
}
|
|
||||||
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
|
|
||||||
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
|
||||||
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
|
||||||
if (tool === "canvas_get_selection") {
|
|
||||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
|
||||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
|
||||||
}
|
|
||||||
if (tool === "canvas_create_node") {
|
|
||||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
|
||||||
input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_create_text_node") {
|
|
||||||
const text = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
|
|
||||||
input = { ops: [textNodeOp(text, text.x ?? nextCanvasX(this.canvasState), text.y ?? 0)] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_create_text_nodes") {
|
|
||||||
const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" };
|
|
||||||
const x = Number(data.x ?? nextCanvasX(this.canvasState));
|
|
||||||
const y = Number(data.y ?? 0);
|
|
||||||
const gap = Number(data.gap ?? 40);
|
|
||||||
input = {
|
|
||||||
ops: data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))),
|
|
||||||
};
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_create_image_prompt_flow") {
|
|
||||||
input = { ops: generationFlowOps({ ...(input as Record<string, unknown>), mode: "image" }, this.canvasState) };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_create_config_node") {
|
|
||||||
const data = input as Record<string, unknown>;
|
|
||||||
const x = Number(data.x ?? nextCanvasX(this.canvasState));
|
|
||||||
const y = Number(data.y ?? 0);
|
|
||||||
const configId = `config-${crypto.randomUUID()}`;
|
|
||||||
const mode = generationMode(data.mode);
|
|
||||||
const prompt = String(data.prompt || "");
|
|
||||||
input = { ops: [configNodeOp(configId, data, x, y), ...(data.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_create_generation_flow") {
|
|
||||||
input = { ops: generationFlowOps(input as Record<string, unknown>, this.canvasState) };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_generate_text" || tool === "canvas_generate_image" || tool === "canvas_generate_video" || tool === "canvas_generate_audio") {
|
|
||||||
input = { ops: generationFlowOps({ ...(input as Record<string, unknown>), mode: tool.replace("canvas_generate_", ""), autoRun: true }, this.canvasState) };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_update_node") {
|
|
||||||
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
|
|
||||||
input = { ops: [{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_update_node_text") {
|
|
||||||
const data = input as { id: string; text: string; title?: string };
|
|
||||||
input = { ops: [{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_move_nodes") {
|
|
||||||
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
|
|
||||||
input = {
|
|
||||||
ops: data.items.map((item) => {
|
|
||||||
const current = findNode(this.canvasState, item.id);
|
|
||||||
return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_resize_node") {
|
|
||||||
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
|
|
||||||
input = { ops: [{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_delete_nodes") {
|
|
||||||
input = { ops: [{ type: "delete_node", ids: (input as { ids: string[] }).ids }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_connect_nodes") {
|
|
||||||
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
|
|
||||||
input = { ops: data.connections.map((connection) => ({ type: "connect_nodes", ...connection })) };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_select_nodes") {
|
|
||||||
input = { ops: [{ type: "select_nodes", ids: (input as { ids: string[] }).ids }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_set_viewport") {
|
|
||||||
input = { ops: [{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool === "canvas_run_generation") {
|
|
||||||
const data = input as { nodeId: string; mode?: string; prompt?: string };
|
|
||||||
input = { ops: [runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)] };
|
|
||||||
tool = "canvas_apply_ops";
|
|
||||||
}
|
|
||||||
if (tool !== "canvas_apply_ops") throw new Error(`未知工具:${tool}`);
|
|
||||||
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
|
||||||
return await this.requestCanvasTool(tool, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
|
||||||
const requestId = crypto.randomUUID();
|
|
||||||
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
|
|
||||||
if (!client) throw new Error("当前没有已连接画布");
|
|
||||||
sendEvent(client, "tool_call", { requestId, name, input });
|
|
||||||
return await new Promise((resolve, reject) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
this.pending.delete(requestId);
|
|
||||||
reject(new Error("画布操作超时"));
|
|
||||||
}, 30000);
|
|
||||||
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
|
||||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) {
|
|
||||||
return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } };
|
|
||||||
}
|
|
||||||
|
|
||||||
function configNodeOp(id: string, input: Record<string, unknown>, x: number, y: number) {
|
|
||||||
const mode = generationMode(input.mode);
|
|
||||||
const prompt = String(input.prompt || "");
|
|
||||||
return {
|
|
||||||
type: "add_node",
|
|
||||||
id,
|
|
||||||
nodeType: "config",
|
|
||||||
title: String(input.title || generationTitle(mode)),
|
|
||||||
position: { x, y },
|
|
||||||
width: typeof input.width === "number" ? input.width : undefined,
|
|
||||||
height: typeof input.height === "number" ? input.height : undefined,
|
|
||||||
metadata: cleanRecord({
|
|
||||||
generationMode: mode,
|
|
||||||
composerContent: prompt,
|
|
||||||
prompt,
|
|
||||||
status: "idle",
|
|
||||||
model: input.model,
|
|
||||||
size: input.size,
|
|
||||||
quality: input.quality,
|
|
||||||
count: input.count,
|
|
||||||
seconds: input.seconds,
|
|
||||||
vquality: input.vquality,
|
|
||||||
generateAudio: input.generateAudio,
|
|
||||||
watermark: input.watermark,
|
|
||||||
audioVoice: input.audioVoice,
|
|
||||||
audioFormat: input.audioFormat,
|
|
||||||
audioSpeed: input.audioSpeed,
|
|
||||||
audioInstructions: input.audioInstructions,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generationFlowOps(input: Record<string, unknown>, state: CanvasSnapshot | null) {
|
|
||||||
const mode = generationMode(input.mode);
|
|
||||||
const prompt = String(input.prompt || "");
|
|
||||||
const x = Number(input.x ?? nextCanvasX(state));
|
|
||||||
const y = Number(input.y ?? 0);
|
|
||||||
const textId = `text-${crypto.randomUUID()}`;
|
|
||||||
const configId = `config-${crypto.randomUUID()}`;
|
|
||||||
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
|
||||||
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
|
|
||||||
const configInput = { ...input, prompt: tokens.join("\n") };
|
|
||||||
return [
|
|
||||||
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
|
|
||||||
configNodeOp(configId, configInput, x + 420, y),
|
|
||||||
{ type: "connect_nodes", fromNodeId: textId, toNodeId: configId },
|
|
||||||
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })),
|
|
||||||
{ type: "select_nodes", ids: [configId] },
|
|
||||||
...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) {
|
|
||||||
return { type: "run_generation", nodeId, mode, prompt };
|
|
||||||
}
|
|
||||||
|
|
||||||
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
|
||||||
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
|
||||||
if (mode === "text") return "文本生成";
|
|
||||||
if (mode === "video") return "视频生成";
|
|
||||||
if (mode === "audio") return "音频生成";
|
|
||||||
return "图片生成";
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined {
|
|
||||||
return (state?.nodes || []).find((node) => node.id === id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanRecord(value: Record<string, unknown>) {
|
|
||||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
|
import type { ToolName } from "./schemas.js";
|
||||||
|
import { nextCanvasX } from "./tools.js";
|
||||||
|
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||||
|
|
||||||
|
export type CanvasToolRequest = { name: "canvas_apply_ops"; input: Record<string, unknown> };
|
||||||
|
|
||||||
|
/** 将上层画布工具调用转换为前端可执行的批量操作。 */
|
||||||
|
export function buildCanvasToolRequest(name: ToolName, input: Record<string, unknown>, state: CanvasSnapshot | null): CanvasToolRequest {
|
||||||
|
if (name === "canvas_apply_ops") return { name, input };
|
||||||
|
if (name === "canvas_create_node") {
|
||||||
|
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||||
|
return applyOps([{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(state), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }]);
|
||||||
|
}
|
||||||
|
if (name === "canvas_create_text_node") {
|
||||||
|
const data = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
|
||||||
|
return applyOps([textNodeOp(data, data.x ?? nextCanvasX(state), data.y ?? 0)]);
|
||||||
|
}
|
||||||
|
if (name === "canvas_create_text_nodes") {
|
||||||
|
const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" };
|
||||||
|
const x = Number(data.x ?? nextCanvasX(state));
|
||||||
|
const y = Number(data.y ?? 0);
|
||||||
|
const gap = Number(data.gap ?? 40);
|
||||||
|
return applyOps(data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))));
|
||||||
|
}
|
||||||
|
if (name === "canvas_create_image_prompt_flow") return applyOps(generationFlowOps({ ...input, mode: "image" }, state));
|
||||||
|
if (name === "canvas_create_config_node") {
|
||||||
|
const x = Number(input.x ?? nextCanvasX(state));
|
||||||
|
const y = Number(input.y ?? 0);
|
||||||
|
const configId = `config-${crypto.randomUUID()}`;
|
||||||
|
const mode = generationMode(input.mode);
|
||||||
|
const prompt = String(input.prompt || "");
|
||||||
|
return applyOps([configNodeOp(configId, input, x, y), ...(input.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])]);
|
||||||
|
}
|
||||||
|
if (name === "canvas_create_generation_flow") return applyOps(generationFlowOps(input, state));
|
||||||
|
if (name === "canvas_generate_text" || name === "canvas_generate_image" || name === "canvas_generate_video" || name === "canvas_generate_audio") {
|
||||||
|
return applyOps(generationFlowOps({ ...input, mode: name.replace("canvas_generate_", ""), autoRun: true }, state));
|
||||||
|
}
|
||||||
|
if (name === "canvas_update_node") {
|
||||||
|
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
|
||||||
|
return applyOps([{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }]);
|
||||||
|
}
|
||||||
|
if (name === "canvas_update_node_text") {
|
||||||
|
const data = input as { id: string; text: string; title?: string };
|
||||||
|
return applyOps([{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }]);
|
||||||
|
}
|
||||||
|
if (name === "canvas_move_nodes") {
|
||||||
|
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
|
||||||
|
return applyOps(data.items.map((item) => {
|
||||||
|
const current = findNode(state, item.id);
|
||||||
|
return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } };
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (name === "canvas_resize_node") {
|
||||||
|
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
|
||||||
|
return applyOps([{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }]);
|
||||||
|
}
|
||||||
|
if (name === "canvas_delete_nodes") return applyOps([{ type: "delete_node", ids: (input as { ids: string[] }).ids }]);
|
||||||
|
if (name === "canvas_connect_nodes") {
|
||||||
|
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
|
||||||
|
return applyOps(data.connections.map((connection) => ({ type: "connect_nodes", ...connection })));
|
||||||
|
}
|
||||||
|
if (name === "canvas_select_nodes") return applyOps([{ type: "select_nodes", ids: (input as { ids: string[] }).ids }]);
|
||||||
|
if (name === "canvas_set_viewport") return applyOps([{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }]);
|
||||||
|
if (name === "canvas_run_generation") {
|
||||||
|
const data = input as { nodeId: string; mode?: string; prompt?: string };
|
||||||
|
return applyOps([runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)]);
|
||||||
|
}
|
||||||
|
throw new Error(`未知工具:${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按最大边限制计算附件图片节点尺寸,并保持原始比例。 */
|
||||||
|
export function fitAttachmentNodeSize(width: number, height: number) {
|
||||||
|
const scale = Math.min(1, 640 / width, 640 / height);
|
||||||
|
return { width: width * scale, height: height * scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建统一的批量画布操作请求。 */
|
||||||
|
function applyOps(ops: unknown[]): CanvasToolRequest {
|
||||||
|
return { name: "canvas_apply_ops", input: { ops } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建文本节点操作。 */
|
||||||
|
function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) {
|
||||||
|
return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建生成配置节点操作。 */
|
||||||
|
function configNodeOp(id: string, input: Record<string, unknown>, x: number, y: number) {
|
||||||
|
const mode = generationMode(input.mode);
|
||||||
|
const prompt = String(input.prompt || "");
|
||||||
|
return {
|
||||||
|
type: "add_node",
|
||||||
|
id,
|
||||||
|
nodeType: "config",
|
||||||
|
title: String(input.title || generationTitle(mode)),
|
||||||
|
position: { x, y },
|
||||||
|
width: typeof input.width === "number" ? input.width : undefined,
|
||||||
|
height: typeof input.height === "number" ? input.height : undefined,
|
||||||
|
metadata: cleanRecord({
|
||||||
|
generationMode: mode,
|
||||||
|
composerContent: prompt,
|
||||||
|
prompt,
|
||||||
|
status: "idle",
|
||||||
|
model: input.model,
|
||||||
|
size: input.size,
|
||||||
|
quality: input.quality,
|
||||||
|
count: input.count,
|
||||||
|
seconds: input.seconds,
|
||||||
|
vquality: input.vquality,
|
||||||
|
generateAudio: input.generateAudio,
|
||||||
|
watermark: input.watermark,
|
||||||
|
audioVoice: input.audioVoice,
|
||||||
|
audioFormat: input.audioFormat,
|
||||||
|
audioSpeed: input.audioSpeed,
|
||||||
|
audioInstructions: input.audioInstructions,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建包含提示词、配置节点和引用连线的生成流程。 */
|
||||||
|
function generationFlowOps(input: Record<string, unknown>, state: CanvasSnapshot | null) {
|
||||||
|
const mode = generationMode(input.mode);
|
||||||
|
const prompt = String(input.prompt || "");
|
||||||
|
const x = Number(input.x ?? nextCanvasX(state));
|
||||||
|
const y = Number(input.y ?? 0);
|
||||||
|
const textId = `text-${crypto.randomUUID()}`;
|
||||||
|
const configId = `config-${crypto.randomUUID()}`;
|
||||||
|
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||||||
|
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
|
||||||
|
return [
|
||||||
|
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
|
||||||
|
configNodeOp(configId, { ...input, prompt: tokens.join("\n") }, x + 420, y),
|
||||||
|
{ type: "connect_nodes", fromNodeId: textId, toNodeId: configId },
|
||||||
|
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })),
|
||||||
|
{ type: "select_nodes", ids: [configId] },
|
||||||
|
...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建触发节点生成的画布操作。 */
|
||||||
|
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) {
|
||||||
|
return { type: "run_generation", nodeId, mode, prompt };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将未知生成模式归一为画布支持的模式。 */
|
||||||
|
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
||||||
|
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取生成模式对应的默认节点标题。 */
|
||||||
|
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
||||||
|
if (mode === "text") return "文本生成";
|
||||||
|
if (mode === "video") return "视频生成";
|
||||||
|
if (mode === "audio") return "音频生成";
|
||||||
|
return "图片生成";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按节点 ID 查找当前画布节点。 */
|
||||||
|
function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined {
|
||||||
|
return (state?.nodes || []).find((node) => node.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移除对象中未设置的生成参数。 */
|
||||||
|
function cleanRecord(value: Record<string, unknown>) {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() })
|
|||||||
const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
|
const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
|
||||||
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
|
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
|
||||||
|
|
||||||
|
/** Canvas Agent 对外提供的工具名称。 */
|
||||||
export const toolNames = [
|
export const toolNames = [
|
||||||
"site_navigate",
|
"site_navigate",
|
||||||
"canvas_list_projects",
|
"canvas_list_projects",
|
||||||
@@ -14,6 +15,7 @@ export const toolNames = [
|
|||||||
"canvas_export_snapshot",
|
"canvas_export_snapshot",
|
||||||
"canvas_apply_ops",
|
"canvas_apply_ops",
|
||||||
"canvas_create_node",
|
"canvas_create_node",
|
||||||
|
"canvas_create_attachment_nodes",
|
||||||
"canvas_create_text_node",
|
"canvas_create_text_node",
|
||||||
"canvas_create_text_nodes",
|
"canvas_create_text_nodes",
|
||||||
"canvas_create_config_node",
|
"canvas_create_config_node",
|
||||||
@@ -32,6 +34,7 @@ export const toolNames = [
|
|||||||
"canvas_select_nodes",
|
"canvas_select_nodes",
|
||||||
"canvas_set_viewport",
|
"canvas_set_viewport",
|
||||||
"canvas_run_generation",
|
"canvas_run_generation",
|
||||||
|
"generation_get_status",
|
||||||
"workbench_image_get_config",
|
"workbench_image_get_config",
|
||||||
"workbench_image_generate",
|
"workbench_image_generate",
|
||||||
"workbench_video_get_config",
|
"workbench_video_get_config",
|
||||||
@@ -93,6 +96,7 @@ export const toolInputSchemas = {
|
|||||||
canvas_export_snapshot: z.object({}).passthrough(),
|
canvas_export_snapshot: z.object({}).passthrough(),
|
||||||
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
|
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
|
||||||
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
|
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
|
||||||
|
canvas_create_attachment_nodes: z.object({ attachmentIds: z.array(z.string()).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||||
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
|
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
|
||||||
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||||
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||||
@@ -111,6 +115,7 @@ export const toolInputSchemas = {
|
|||||||
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
|
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
|
||||||
canvas_set_viewport: z.object({ viewport: viewportSchema }),
|
canvas_set_viewport: z.object({ viewport: viewportSchema }),
|
||||||
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
|
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
|
||||||
|
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
|
||||||
workbench_image_get_config: z.object({}).passthrough(),
|
workbench_image_get_config: z.object({}).passthrough(),
|
||||||
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
|
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
|
||||||
workbench_video_get_config: z.object({}).passthrough(),
|
workbench_video_get_config: z.object({}).passthrough(),
|
||||||
@@ -128,6 +133,7 @@ export const toolDescriptions: Record<ToolName, string> = {
|
|||||||
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
||||||
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
|
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
|
||||||
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
|
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
|
||||||
|
canvas_create_attachment_nodes: "把当前对话中用户上传的图片附件创建成真实画布图片节点。attachmentIds 使用本轮附件清单中的 ID;返回的节点 ID 可传给 canvas_create_generation_flow.referenceNodeIds 作为生成参考图。",
|
||||||
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
||||||
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||||
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
|
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
|
||||||
@@ -146,10 +152,11 @@ export const toolDescriptions: Record<ToolName, string> = {
|
|||||||
canvas_select_nodes: "设置当前选中节点。",
|
canvas_select_nodes: "设置当前选中节点。",
|
||||||
canvas_set_viewport: "调整画布视口。",
|
canvas_set_viewport: "调整画布视口。",
|
||||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||||
|
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
|
||||||
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
||||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
|
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
|
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { ServerResponse } from "node:http";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { CanvasSession } from "./session.js";
|
||||||
|
|
||||||
|
test("MCP 读取当前激活网页的画布", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.updateState(snapshot("canvas-first"), "first");
|
||||||
|
session.updateState(snapshot("canvas-second"), "second");
|
||||||
|
|
||||||
|
session.activateClient("first");
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||||
|
|
||||||
|
session.activateClient("second");
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("画布写操作只发送给当前激活网页", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.updateState(snapshot("canvas-first"), "first");
|
||||||
|
session.updateState(snapshot("canvas-second"), "second");
|
||||||
|
session.activateClient("second");
|
||||||
|
|
||||||
|
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
|
||||||
|
const call = second.event("tool_call");
|
||||||
|
assert.equal(first.event("tool_call"), undefined);
|
||||||
|
assert.equal(field(call, "name"), "canvas_apply_ops");
|
||||||
|
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||||
|
assert.deepEqual(await result, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("当前 turn 的图片附件可在发起标签页画布创建图片节点", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
t.after(() => first.close());
|
||||||
|
const dataUrl = "data:image/png;base64,aW1hZ2U=";
|
||||||
|
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", size: 5, width: 1200, height: 600, dataUrl }]);
|
||||||
|
session.bindClient("first");
|
||||||
|
|
||||||
|
const result = session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"], x: 100, y: 200 });
|
||||||
|
const call = first.event("tool_call");
|
||||||
|
const input = field(call, "input") as Record<string, unknown>;
|
||||||
|
const nodes = input.nodes as Array<Record<string, unknown>>;
|
||||||
|
assert.equal(field(call, "name"), "canvas_create_attachment_nodes");
|
||||||
|
assert.equal(nodes.length, 1);
|
||||||
|
assert.equal(nodes[0].attachmentId, "attachment-1");
|
||||||
|
assert.equal(nodes[0].title, "商品.png");
|
||||||
|
assert.deepEqual(nodes[0].position, { x: 100, y: 200 });
|
||||||
|
assert.equal(nodes[0].width, 640);
|
||||||
|
assert.equal(nodes[0].height, 320);
|
||||||
|
assert.equal("dataUrl" in nodes[0], false);
|
||||||
|
assert.equal(session.getTurnAttachment("first", "attachment-1").dataUrl, dataUrl);
|
||||||
|
|
||||||
|
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||||
|
const created = (await result) as { nodes: Array<{ id: string; attachmentId: string; title: string }> };
|
||||||
|
assert.equal(created.nodes[0].id, nodes[0].id);
|
||||||
|
assert.equal(created.nodes[0].attachmentId, "attachment-1");
|
||||||
|
session.clearTurnAttachments("first");
|
||||||
|
assert.throws(() => session.getTurnAttachment("first", "attachment-1"), /找不到/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("图片附件只允许发起 turn 的标签页读取和落入画布", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", dataUrl: "data:image/png;base64,aW1hZ2U=" }]);
|
||||||
|
session.bindClient("second");
|
||||||
|
|
||||||
|
await assert.rejects(session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"] }), /发起标签页/);
|
||||||
|
assert.throws(() => session.getTurnAttachment("second", "attachment-1"), /发起标签页/);
|
||||||
|
assert.equal(first.event("tool_call"), undefined);
|
||||||
|
assert.equal(second.event("tool_call"), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tool result is accepted only from the request client", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.activateClient("first");
|
||||||
|
|
||||||
|
const result = session.callTool("canvas_create_text_node", { text: "first only" });
|
||||||
|
const call = first.event("tool_call");
|
||||||
|
const requestId = String(field(call, "requestId"));
|
||||||
|
|
||||||
|
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
|
||||||
|
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
|
||||||
|
assert.deepEqual(await result, { client: "first" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("生成状态查询由当前激活网页返回", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.activateClient("second");
|
||||||
|
|
||||||
|
const result = session.callTool("generation_get_status", { scope: "all" });
|
||||||
|
const call = second.event("tool_call");
|
||||||
|
assert.equal(first.event("tool_call"), undefined);
|
||||||
|
assert.equal(field(call, "name"), "generation_get_status");
|
||||||
|
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
|
||||||
|
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("活动网页关闭后回退到仍连接的画布", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.updateState(snapshot("canvas-first"), "first");
|
||||||
|
session.updateState(snapshot("canvas-second"), "second");
|
||||||
|
session.activateClient("second");
|
||||||
|
second.close();
|
||||||
|
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the active client falls back to the most recently focused client", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
const third = connect(session, "third");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
third.close();
|
||||||
|
});
|
||||||
|
session.updateState(snapshot("canvas-first"), "first");
|
||||||
|
session.updateState(snapshot("canvas-second"), "second");
|
||||||
|
session.updateState(snapshot("canvas-third"), "third");
|
||||||
|
session.activateClient("third");
|
||||||
|
session.activateClient("second");
|
||||||
|
second.close();
|
||||||
|
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing a client rejects its pending tool requests", async () => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const result = session.callTool("canvas_create_text_node", { text: "pending" });
|
||||||
|
const call = first.event("tool_call");
|
||||||
|
const requestId = String(field(call, "requestId"));
|
||||||
|
first.close();
|
||||||
|
|
||||||
|
const outcome = await Promise.race([
|
||||||
|
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
|
||||||
|
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
|
||||||
|
]);
|
||||||
|
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
|
||||||
|
assert.match(outcome, /断开/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared thread events are broadcast with the active thread id", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
|
||||||
|
|
||||||
|
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||||
|
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("new clients receive the current Codex state and later updates", (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||||
|
const client = connect(session, "first");
|
||||||
|
t.after(() => client.close());
|
||||||
|
|
||||||
|
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||||
|
|
||||||
|
session.setCodexState({ busy: false });
|
||||||
|
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a bound client remains the tool target while focus changes", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.updateState(snapshot("canvas-first"), "first");
|
||||||
|
session.updateState(snapshot("canvas-second"), "second");
|
||||||
|
session.bindClient("first");
|
||||||
|
session.activateClient("second");
|
||||||
|
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||||
|
const result = session.callTool("canvas_create_text_node", { text: "bound" });
|
||||||
|
const call = first.event("tool_call");
|
||||||
|
assert.equal(second.event("tool_call"), undefined);
|
||||||
|
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||||
|
assert.deepEqual(await result, { ok: true });
|
||||||
|
|
||||||
|
session.releaseClient("first");
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closing the bound client falls back to the active client", async (t) => {
|
||||||
|
const session = new CanvasSession();
|
||||||
|
const first = connect(session, "first");
|
||||||
|
const second = connect(session, "second");
|
||||||
|
t.after(() => {
|
||||||
|
first.close();
|
||||||
|
second.close();
|
||||||
|
});
|
||||||
|
session.updateState(snapshot("canvas-first"), "first");
|
||||||
|
session.updateState(snapshot("canvas-second"), "second");
|
||||||
|
session.bindClient("first");
|
||||||
|
session.activateClient("second");
|
||||||
|
first.close();
|
||||||
|
|
||||||
|
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||||
|
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
|
||||||
|
const call = second.event("tool_call");
|
||||||
|
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||||
|
assert.deepEqual(await result, { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 创建用于测试的画布 SSE 连接。 */
|
||||||
|
function connect(session: CanvasSession, clientId: string) {
|
||||||
|
const response = new FakeSseResponse();
|
||||||
|
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建最小画布快照。 */
|
||||||
|
function snapshot(projectId: string) {
|
||||||
|
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 安全读取测试对象字段。 */
|
||||||
|
function field(value: unknown, key: string) {
|
||||||
|
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟 Node SSE 响应并提供事件读取能力。 */
|
||||||
|
class FakeSseResponse extends EventEmitter {
|
||||||
|
private chunks: string[] = [];
|
||||||
|
|
||||||
|
/** 模拟写入响应头。 */
|
||||||
|
writeHead() {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存写入的 SSE 文本块。 */
|
||||||
|
write(chunk: string) {
|
||||||
|
this.chunks.push(chunk);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取指定类型的首个 SSE 事件数据。 */
|
||||||
|
event(type: string) {
|
||||||
|
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
||||||
|
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||||
|
return data ? (JSON.parse(data) as unknown) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 触发连接关闭事件。 */
|
||||||
|
close() {
|
||||||
|
this.emit("close");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { ServerResponse } from "node:http";
|
||||||
|
|
||||||
|
import type { AgentAttachment } from "../agent/types.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
import { buildCanvasToolRequest, fitAttachmentNodeSize } from "./operations.js";
|
||||||
|
import type { ToolName } from "./schemas.js";
|
||||||
|
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||||
|
import type { CanvasSnapshot } from "./types.js";
|
||||||
|
|
||||||
|
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||||
|
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||||
|
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||||
|
|
||||||
|
const SITE_TOOLS = new Set<ToolName>([
|
||||||
|
"site_navigate",
|
||||||
|
"canvas_list_projects",
|
||||||
|
"workbench_image_get_config",
|
||||||
|
"workbench_image_generate",
|
||||||
|
"workbench_video_get_config",
|
||||||
|
"workbench_video_generate",
|
||||||
|
"prompts_search",
|
||||||
|
"assets_list",
|
||||||
|
"assets_add",
|
||||||
|
"generation_get_status",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 管理网页画布连接、状态、附件和工具请求。 */
|
||||||
|
export class CanvasSession {
|
||||||
|
private clients = new Map<string, ServerResponse>();
|
||||||
|
private clientFocusOrder = new Map<string, number>();
|
||||||
|
private pending = new Map<string, PendingRequest>();
|
||||||
|
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||||
|
private turnAttachments = new Map<string, TurnAttachment>();
|
||||||
|
private activeClientId = "";
|
||||||
|
private boundClientId = "";
|
||||||
|
private focusSequence = 0;
|
||||||
|
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||||
|
|
||||||
|
/** 获取当前目标网页的画布状态。 */
|
||||||
|
private get canvasState() {
|
||||||
|
return this.canvasStates.get(this.targetClientId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前 turn 绑定或最近激活的网页客户端。 */
|
||||||
|
private get targetClientId() {
|
||||||
|
return this.boundClientId || this.activeClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回 Canvas Agent 当前连接状态。 */
|
||||||
|
health() {
|
||||||
|
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 返回 Codex 是否正在执行任务。 */
|
||||||
|
get codexBusy() {
|
||||||
|
return this.codexState.busy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新并广播 Codex 运行状态。 */
|
||||||
|
setCodexState(patch: Partial<CodexState>) {
|
||||||
|
const next = { ...this.codexState, ...patch };
|
||||||
|
if (next.busy === this.codexState.busy && next.threadId === this.codexState.threadId && next.turnId === this.codexState.turnId) return;
|
||||||
|
this.codexState = next;
|
||||||
|
logger.debug("Codex state changed", this.codexState);
|
||||||
|
this.emitAll("codex_state", this.codexState);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建立网页与 Canvas Agent 之间的 SSE 连接。 */
|
||||||
|
openEvents(url: URL, res: ServerResponse) {
|
||||||
|
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||||
|
const statusOnly = url.searchParams.get("role") === "status";
|
||||||
|
logger.info("SSE client connected", { clientId, statusOnly });
|
||||||
|
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||||
|
if (!statusOnly) {
|
||||||
|
this.clients.set(clientId, res);
|
||||||
|
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||||
|
if (!this.activeClientId) {
|
||||||
|
this.activeClientId = clientId;
|
||||||
|
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||||
|
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||||
|
res.on("close", () => {
|
||||||
|
clearInterval(timer);
|
||||||
|
logger.info("SSE client disconnected", { clientId, statusOnly });
|
||||||
|
if (statusOnly || this.clients.get(clientId) !== res) return;
|
||||||
|
this.clients.delete(clientId);
|
||||||
|
this.clientFocusOrder.delete(clientId);
|
||||||
|
this.canvasStates.delete(clientId);
|
||||||
|
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||||
|
this.pending.forEach((item, requestId) => {
|
||||||
|
if (item.clientId !== clientId) return;
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
item.reject(new Error("请求页面已断开"));
|
||||||
|
});
|
||||||
|
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存指定网页上报的最新画布快照。 */
|
||||||
|
updateState(body: unknown, clientId?: string) {
|
||||||
|
const targetClientId = clientId || this.activeClientId;
|
||||||
|
if (!targetClientId) return;
|
||||||
|
this.canvasStates.set(targetClientId, { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot);
|
||||||
|
logger.debug("Canvas state updated", { clientId: targetClientId, nodes: Array.isArray((body as CanvasSnapshot | null)?.nodes) ? (body as CanvasSnapshot).nodes.length : 0, connections: Array.isArray((body as CanvasSnapshot | null)?.connections) ? (body as CanvasSnapshot).connections.length : 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将指定网页设为最近激活的工具目标。 */
|
||||||
|
activateClient(clientId: string) {
|
||||||
|
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||||
|
this.activeClientId = clientId;
|
||||||
|
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||||
|
logger.debug("Canvas client activated", { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将当前 Agent turn 固定绑定到指定网页。 */
|
||||||
|
bindClient(clientId: string) {
|
||||||
|
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||||
|
this.boundClientId = clientId;
|
||||||
|
logger.debug("Canvas client bound to turn", { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解除当前 Agent turn 的网页绑定。 */
|
||||||
|
releaseClient(clientId: string) {
|
||||||
|
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||||
|
logger.debug("Canvas client released from turn", { clientId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存当前 turn 可用的图片附件并返回安全引用。 */
|
||||||
|
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
|
||||||
|
this.turnAttachments.clear();
|
||||||
|
return attachments.flatMap((item, index) => {
|
||||||
|
if (!item.dataUrl?.startsWith("data:image/")) return [];
|
||||||
|
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
|
||||||
|
const attachment: TurnAttachment = {
|
||||||
|
clientId,
|
||||||
|
id,
|
||||||
|
name: item.name?.trim() || `图片 ${index + 1}`,
|
||||||
|
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
|
||||||
|
size: positiveNumber(item.size, 0),
|
||||||
|
width: positiveNumber(item.width, 1024),
|
||||||
|
height: positiveNumber(item.height, 1024),
|
||||||
|
dataUrl: item.dataUrl,
|
||||||
|
};
|
||||||
|
this.turnAttachments.set(id, attachment);
|
||||||
|
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理指定网页或全部 turn 附件。 */
|
||||||
|
clearTurnAttachments(clientId?: string) {
|
||||||
|
this.turnAttachments.forEach((item, id) => {
|
||||||
|
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取属于指定网页 turn 的图片附件。 */
|
||||||
|
getTurnAttachment(clientId: string, attachmentId: string) {
|
||||||
|
const attachment = this.turnAttachments.get(attachmentId);
|
||||||
|
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
|
||||||
|
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
|
||||||
|
return attachment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接收网页返回的工具调用结果。 */
|
||||||
|
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
|
||||||
|
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||||
|
if (!item || !body.requestId || item.clientId !== clientId) return false;
|
||||||
|
this.pending.delete(body.requestId);
|
||||||
|
logger.debug("Canvas tool result received", { clientId, requestId: body.requestId, error: body.error, result: body.result });
|
||||||
|
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向全部已连接网页广播事件。 */
|
||||||
|
emitAll(type: string, payload: unknown) {
|
||||||
|
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向全部网页广播带线程归属的事件。 */
|
||||||
|
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||||
|
this.emitAll(type, { ...payload, threadId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验工具参数并将调用分派到当前目标网页。 */
|
||||||
|
async callTool(name: unknown, rawInput: unknown) {
|
||||||
|
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||||
|
logger.info("MCP tool called", { name, input: rawInput, targetClientId: this.targetClientId });
|
||||||
|
const input = parseToolInput(name, rawInput) as Record<string, unknown>;
|
||||||
|
if (SITE_TOOLS.has(name)) {
|
||||||
|
if (!this.clients.size) throw new Error("当前没有已连接网页");
|
||||||
|
return await this.requestCanvasTool(name, input);
|
||||||
|
}
|
||||||
|
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(name);
|
||||||
|
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
||||||
|
if (name === "canvas_get_state" || name === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
||||||
|
if (name === "canvas_get_selection") {
|
||||||
|
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||||
|
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||||
|
}
|
||||||
|
if (name === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
|
||||||
|
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
||||||
|
const request = buildCanvasToolRequest(name, input, this.canvasState);
|
||||||
|
return await this.requestCanvasTool(request.name, request.input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将当前 turn 的附件转换为画布图片节点。 */
|
||||||
|
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
|
||||||
|
const clientId = this.targetClientId;
|
||||||
|
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
|
||||||
|
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
|
||||||
|
const x = Number(input.x ?? nextCanvasX(this.canvasState));
|
||||||
|
const y = Number(input.y ?? 0);
|
||||||
|
const gap = Number(input.gap ?? 40);
|
||||||
|
const direction = input.direction || "row";
|
||||||
|
let offset = 0;
|
||||||
|
const nodes = attachments.map((attachment) => {
|
||||||
|
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
|
||||||
|
const node = {
|
||||||
|
id: `image-${crypto.randomUUID()}`,
|
||||||
|
attachmentId: attachment.id,
|
||||||
|
title: attachment.name,
|
||||||
|
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
};
|
||||||
|
offset += (direction === "row" ? size.width : size.height) + gap;
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
|
||||||
|
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向目标网页发送工具请求并等待调用结果。 */
|
||||||
|
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||||
|
const requestId = crypto.randomUUID();
|
||||||
|
const clientId = this.targetClientId;
|
||||||
|
const client = this.clients.get(clientId);
|
||||||
|
if (!client) throw new Error("当前没有已连接画布");
|
||||||
|
sendEvent(client, "tool_call", { requestId, name, input });
|
||||||
|
logger.debug("Canvas tool request sent", { requestId, name, input, clientId });
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
logger.warn("Canvas tool request timed out", { requestId, name, clientId });
|
||||||
|
reject(new Error("画布操作超时"));
|
||||||
|
}, 30000);
|
||||||
|
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向 SSE 连接写入一个事件。 */
|
||||||
|
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||||
|
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||||
|
}
|
||||||
|
/** 将未知数值转换为正数,否则使用默认值。 */
|
||||||
|
function positiveNumber(value: unknown, fallback: number) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||||
|
}
|
||||||
@@ -1,25 +1,30 @@
|
|||||||
import { toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
import { toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||||
import type { CanvasNode, CanvasSnapshot } from "./types.js";
|
import type { CanvasNode, CanvasSnapshot } from "./types.js";
|
||||||
|
|
||||||
|
/** 判断传入名称是否为已注册的画布工具。 */
|
||||||
export function isToolName(name: unknown): name is ToolName {
|
export function isToolName(name: unknown): name is ToolName {
|
||||||
return typeof name === "string" && toolNames.includes(name as ToolName);
|
return typeof name === "string" && toolNames.includes(name as ToolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按工具名称校验并解析调用参数。 */
|
||||||
export function parseToolInput(name: ToolName, input: unknown) {
|
export function parseToolInput(name: ToolName, input: unknown) {
|
||||||
return toolInputSchemas[name].parse(input ?? {});
|
return toolInputSchemas[name].parse(input ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 压缩画布快照,避免向 Agent 返回过长的节点内容。 */
|
||||||
export function compactCanvasState(state: CanvasSnapshot | null) {
|
export function compactCanvasState(state: CanvasSnapshot | null) {
|
||||||
if (!state) throw new Error("当前没有已连接画布");
|
if (!state) throw new Error("当前没有已连接画布");
|
||||||
return { ...state, nodes: (state.nodes || []).map(compactNode) };
|
return { ...state, nodes: (state.nodes || []).map(compactNode) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 压缩单个画布节点的元数据内容。 */
|
||||||
export function compactNode(node: CanvasNode) {
|
export function compactNode(node: CanvasNode) {
|
||||||
const metadata = { ...(node.metadata || {}) };
|
const metadata = { ...(node.metadata || {}) };
|
||||||
if (typeof metadata.content === "string" && metadata.content.length > 240) metadata.content = `${metadata.content.slice(0, 120)}...`;
|
if (typeof metadata.content === "string" && metadata.content.length > 240) metadata.content = `${metadata.content.slice(0, 120)}...`;
|
||||||
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, metadata };
|
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, metadata };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 计算新节点在当前画布右侧的默认横坐标。 */
|
||||||
export function nextCanvasX(state: CanvasSnapshot | null) {
|
export function nextCanvasX(state: CanvasSnapshot | null) {
|
||||||
const nodes = state?.nodes || [];
|
const nodes = state?.nodes || [];
|
||||||
return nodes.length ? Math.max(...nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
return nodes.length ? Math.max(...nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
|
/** 画布坐标。 */
|
||||||
export type Position = { x: number; y: number };
|
export type Position = { x: number; y: number };
|
||||||
export type Viewport = { x: number; y: number; k: number };
|
export type Viewport = { x: number; y: number; k: number };
|
||||||
export type CanvasNodeType = "image" | "text" | "config" | "video" | "audio";
|
export type CanvasNodeType = "image" | "text" | "config" | "video" | "audio";
|
||||||
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; metadata?: Record<string, unknown> };
|
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; metadata?: Record<string, unknown> };
|
||||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
|
||||||
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string };
|
|
||||||
@@ -7,11 +7,13 @@ export const DEFAULT_PORT = 17371;
|
|||||||
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
|
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
|
||||||
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
|
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
|
||||||
export const VERSION = readPackageVersion();
|
export const VERSION = readPackageVersion();
|
||||||
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
export const AGENT_PROMPT = fs.readFileSync(new URL("../agent-instructions.md", import.meta.url), "utf8");
|
||||||
|
const initializedWorkspaces = new Set<string>();
|
||||||
|
|
||||||
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
||||||
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
|
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
|
||||||
|
|
||||||
|
/** 读取本地 Canvas Agent 配置,不存在时生成默认配置。 */
|
||||||
export function loadConfig(create = false): CanvasAgentConfig {
|
export function loadConfig(create = false): CanvasAgentConfig {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) as CanvasAgentConfig;
|
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) as CanvasAgentConfig;
|
||||||
@@ -22,41 +24,56 @@ export function loadConfig(create = false): CanvasAgentConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将 Canvas Agent 配置写入用户配置目录。 */
|
||||||
export function saveConfig(config: CanvasAgentConfig) {
|
export function saveConfig(config: CanvasAgentConfig) {
|
||||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 确保站点级 Codex 工作空间存在并已初始化。 */
|
||||||
export function ensureSiteWorkspace(config: CanvasAgentConfig) {
|
export function ensureSiteWorkspace(config: CanvasAgentConfig) {
|
||||||
const current = config.workspace;
|
const current = config.workspace;
|
||||||
if (current?.workspacePath) {
|
if (current?.workspacePath) {
|
||||||
const workspacePath = resolveWorkspacePath(current.workspacePath);
|
const workspacePath = resolveWorkspacePath(current.workspacePath);
|
||||||
fs.mkdirSync(workspacePath, { recursive: true });
|
initializeWorkspace(workspacePath);
|
||||||
return { ...current, workspacePath };
|
return { ...current, workspacePath };
|
||||||
}
|
}
|
||||||
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
|
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
|
||||||
config.workspace = { workspacePath };
|
config.workspace = { workspacePath };
|
||||||
fs.mkdirSync(workspacePath, { recursive: true });
|
initializeWorkspace(workspacePath);
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
return { workspacePath };
|
return { workspacePath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 更新站点级 Codex 工作空间配置。 */
|
||||||
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
|
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
|
||||||
const current = ensureSiteWorkspace(config);
|
const current = ensureSiteWorkspace(config);
|
||||||
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
|
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
|
||||||
const next = { ...current, ...patch, workspacePath };
|
const next = { ...current, ...patch, workspacePath };
|
||||||
config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
|
config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
|
||||||
fs.mkdirSync(workspacePath, { recursive: true });
|
initializeWorkspace(workspacePath);
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
return config.workspace;
|
return config.workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 创建工作空间目录并写入默认 AGENTS.md。 */
|
||||||
|
function initializeWorkspace(workspacePath: string) {
|
||||||
|
if (initializedWorkspaces.has(workspacePath)) return;
|
||||||
|
fs.mkdirSync(workspacePath, { recursive: true });
|
||||||
|
const instructionsFile = path.join(workspacePath, "AGENTS.md");
|
||||||
|
const current = fs.existsSync(instructionsFile) ? fs.readFileSync(instructionsFile, "utf8") : "";
|
||||||
|
if (!current || current.startsWith("# Infinite Canvas Agent")) fs.writeFileSync(instructionsFile, AGENT_PROMPT);
|
||||||
|
initializedWorkspaces.add(workspacePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将用户输入的工作空间路径解析为绝对路径。 */
|
||||||
function resolveWorkspacePath(value: string) {
|
function resolveWorkspacePath(value: string) {
|
||||||
if (value === "~") return os.homedir();
|
if (value === "~") return os.homedir();
|
||||||
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
||||||
return path.resolve(value);
|
return path.resolve(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 从当前包信息中读取 Canvas Agent 版本号。 */
|
||||||
function readPackageVersion() {
|
function readPackageVersion() {
|
||||||
try {
|
try {
|
||||||
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
|
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
|
||||||
|
|||||||
@@ -1,143 +0,0 @@
|
|||||||
import express, { type NextFunction, type Request, type Response } from "express";
|
|
||||||
|
|
||||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
|
|
||||||
import { CanvasSession } from "./canvas-session.js";
|
|
||||||
import { archiveCodexThread, interruptCodexTurn, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
|
||||||
import type { AgentAttachment } from "./types.js";
|
|
||||||
|
|
||||||
export function startHttpServer() {
|
|
||||||
const config = loadConfig(true);
|
|
||||||
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
|
|
||||||
config.url = `http://127.0.0.1:${port}`;
|
|
||||||
saveConfig(config);
|
|
||||||
|
|
||||||
const session = new CanvasSession();
|
|
||||||
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
|
|
||||||
const app = express();
|
|
||||||
app.disable("x-powered-by");
|
|
||||||
app.use(express.json({ limit: "30mb" }));
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
const url = requestUrl(req, config);
|
|
||||||
if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" });
|
|
||||||
if (req.method === "OPTIONS") return void res.json({});
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
app.get("/health", (_req, res) => res.json(session.health()));
|
|
||||||
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
|
|
||||||
app.use((req, res, next) => {
|
|
||||||
if (validToken(req, requestUrl(req, config), config.token)) return next();
|
|
||||||
res.status(401).json({ ok: false, error: "invalid token" });
|
|
||||||
});
|
|
||||||
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
|
|
||||||
app.post("/canvas/state", (req, res) => {
|
|
||||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
app.post("/canvas/result", (req, res) => {
|
|
||||||
session.resolveResult(req.body);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
|
||||||
app.get("/agent/codex/workspace", (_req, res) => {
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
res.json({ ok: true, workspace });
|
|
||||||
});
|
|
||||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
|
||||||
res.json({ ok: true, workspace, ...result });
|
|
||||||
}));
|
|
||||||
app.post("/agent/codex/threads/new", route(async (_req, res) => {
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
|
||||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
|
||||||
updateSiteWorkspace(config, { activeThreadId });
|
|
||||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
|
||||||
}));
|
|
||||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
const threadId = routeParam(req.params.threadId);
|
|
||||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
|
||||||
}));
|
|
||||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
const threadId = routeParam(req.params.threadId);
|
|
||||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
|
||||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
|
||||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
|
|
||||||
}));
|
|
||||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
const threadId = routeParam(req.params.threadId);
|
|
||||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
|
||||||
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined });
|
|
||||||
res.json({ ok: true });
|
|
||||||
}));
|
|
||||||
app.post("/agent/codex/turn", route(async (req, res) => {
|
|
||||||
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
|
||||||
const workspace = ensureSiteWorkspace(config);
|
|
||||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
|
||||||
if (!threadId) {
|
|
||||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
|
||||||
threadId = String((thread as Record<string, unknown>).id || "");
|
|
||||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
|
||||||
} else if (threadId !== workspace.activeThreadId) {
|
|
||||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
|
||||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
|
||||||
}
|
|
||||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
|
||||||
res.json({ ok: true, threadId });
|
|
||||||
}));
|
|
||||||
app.post("/agent/codex/interrupt", (_req, res) => {
|
|
||||||
const ok = interruptCodexTurn();
|
|
||||||
res.json({ ok });
|
|
||||||
});
|
|
||||||
app.post("/agent/claude/turn", (req, res) => {
|
|
||||||
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
|
|
||||||
res.json({ ok: true });
|
|
||||||
});
|
|
||||||
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
|
|
||||||
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => res.status(500).json({ ok: false, error: error.message }));
|
|
||||||
|
|
||||||
app.listen(port, "127.0.0.1", () => {
|
|
||||||
console.log("Infinite Canvas Agent");
|
|
||||||
console.log(`Local URL: ${config.url}`);
|
|
||||||
console.log(`Connect token: ${config.token}`);
|
|
||||||
console.log("Codex MCP is not installed by this command.");
|
|
||||||
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
|
|
||||||
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
|
|
||||||
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
|
|
||||||
}
|
|
||||||
|
|
||||||
function routeParam(value: string | string[]) {
|
|
||||||
return Array.isArray(value) ? value[0] || "" : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestUrl(req: Request, config: CanvasAgentConfig) {
|
|
||||||
return new URL(req.originalUrl || req.url || "/", config.url);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
|
|
||||||
const origin = req.headers.origin;
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
|
||||||
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
||||||
if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config") return true;
|
|
||||||
config.origins ||= [];
|
|
||||||
if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
|
|
||||||
config.origins.push(origin);
|
|
||||||
saveConfig(config);
|
|
||||||
}
|
|
||||||
res.setHeader("Vary", "Origin");
|
|
||||||
return config.origins.includes(origin);
|
|
||||||
}
|
|
||||||
|
|
||||||
function validToken(req: Request, url: URL, token: string) {
|
|
||||||
const header = req.headers["x-canvas-agent-token"];
|
|
||||||
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
import { startHttpServer } from "./http-server.js";
|
import { startHttpServer } from "./server/http.js";
|
||||||
import { startMcpServer } from "./mcp-server.js";
|
import { startMcpServer } from "./server/mcp.js";
|
||||||
|
|
||||||
if (process.argv[2] === "mcp") await startMcpServer();
|
if (process.argv[2] === "mcp") await startMcpServer();
|
||||||
else startHttpServer();
|
else startHttpServer();
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import express, { type NextFunction, type Request, type Response } from "express";
|
||||||
|
|
||||||
|
import { runClaudeTurn } from "../agent/claude.js";
|
||||||
|
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
|
||||||
|
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
|
||||||
|
import { CanvasSession } from "../canvas/session.js";
|
||||||
|
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
|
||||||
|
import { logger } from "../utils/logger.js";
|
||||||
|
|
||||||
|
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
||||||
|
export function startHttpServer() {
|
||||||
|
const config = loadConfig(true);
|
||||||
|
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
|
||||||
|
config.url = `http://127.0.0.1:${port}`;
|
||||||
|
saveConfig(config);
|
||||||
|
|
||||||
|
const session = new CanvasSession();
|
||||||
|
/** 将 Agent 事件广播到所属线程或全部网页。 */
|
||||||
|
const emit = (type: string, payload: unknown) => {
|
||||||
|
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||||
|
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
|
||||||
|
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
||||||
|
};
|
||||||
|
/** 保存并广播当前站点工作空间的活跃线程。 */
|
||||||
|
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
||||||
|
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
||||||
|
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||||
|
return workspace;
|
||||||
|
};
|
||||||
|
const app = express();
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
app.use(express.json({ limit: "30mb" }));
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (!logger.enabled) return next();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const url = requestUrl(req, config);
|
||||||
|
res.on("finish", () => {
|
||||||
|
if (req.method === "OPTIONS" || (res.statusCode < 400 && ["/health", "/canvas/state", "/canvas/activate"].includes(url.pathname))) return;
|
||||||
|
logger.debug(`HTTP ${req.method} ${url.pathname}`, { status: res.statusCode, durationMs: Date.now() - startedAt });
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const url = requestUrl(req, config);
|
||||||
|
if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" });
|
||||||
|
if (req.method === "OPTIONS") return void res.json({});
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.get("/health", (_req, res) => res.json(session.health()));
|
||||||
|
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (validToken(req, requestUrl(req, config), config.token)) return next();
|
||||||
|
res.status(401).json({ ok: false, error: "invalid token" });
|
||||||
|
});
|
||||||
|
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
|
||||||
|
app.post("/canvas/state", (req, res) => {
|
||||||
|
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.post("/canvas/activate", (req, res) => {
|
||||||
|
session.activateClient(String(req.query.clientId || ""));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.post("/canvas/result", (req, res) => {
|
||||||
|
const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
|
||||||
|
res.status(ok ? 200 : 409).json({ ok });
|
||||||
|
});
|
||||||
|
app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
|
||||||
|
const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
|
||||||
|
const data = attachment.dataUrl.split(",", 2)[1];
|
||||||
|
if (!data) throw new Error("图片附件内容无效");
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.type(attachment.type).send(Buffer.from(data, "base64"));
|
||||||
|
}));
|
||||||
|
app.post("/agent/local-file/reveal", route(async (req, res) => {
|
||||||
|
const filePath = String(req.body?.path || "");
|
||||||
|
if (!path.isAbsolute(filePath)) return res.status(400).json({ ok: false, error: "文件路径必须是绝对路径" });
|
||||||
|
const file = await stat(filePath);
|
||||||
|
await revealLocalFile(filePath, file.isDirectory());
|
||||||
|
res.json({ ok: true });
|
||||||
|
}));
|
||||||
|
app.post("/agent/local-image", route(async (req, res) => {
|
||||||
|
const filePath = String(req.body?.path || "");
|
||||||
|
if (!path.isAbsolute(filePath) || !/\.(?:avif|gif|jpe?g|png|webp)$/i.test(filePath)) return res.status(400).json({ ok: false, error: "图片路径无效" });
|
||||||
|
const file = await stat(filePath);
|
||||||
|
if (!file.isFile()) return res.status(400).json({ ok: false, error: "图片文件无效" });
|
||||||
|
res.setHeader("Cache-Control", "no-store");
|
||||||
|
res.type(path.extname(filePath)).send(await readFile(filePath));
|
||||||
|
}));
|
||||||
|
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||||
|
app.get("/agent/codex/workspace", (_req, res) => {
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
res.json({ ok: true, workspace });
|
||||||
|
});
|
||||||
|
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
|
||||||
|
res.json({ ok: true, workspace, ...result });
|
||||||
|
}));
|
||||||
|
app.post("/agent/codex/threads/new", route(async (req, res) => {
|
||||||
|
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||||
|
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||||
|
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
|
||||||
|
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||||
|
}));
|
||||||
|
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
const threadId = routeParam(req.params.threadId);
|
||||||
|
try {
|
||||||
|
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||||
|
} catch (error) {
|
||||||
|
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
|
||||||
|
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||||
|
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
const threadId = routeParam(req.params.threadId);
|
||||||
|
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||||
|
const nextWorkspace = setActiveThread(threadId);
|
||||||
|
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||||
|
}));
|
||||||
|
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||||
|
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
const threadId = routeParam(req.params.threadId);
|
||||||
|
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||||
|
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
|
||||||
|
res.json({ ok: true });
|
||||||
|
}));
|
||||||
|
app.post("/agent/codex/turn", route(async (req, res) => {
|
||||||
|
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||||
|
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
||||||
|
const workspace = ensureSiteWorkspace(config);
|
||||||
|
const prompt = String(req.body?.prompt || "");
|
||||||
|
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
|
||||||
|
const clientId = String(req.body?.clientId || "");
|
||||||
|
logger.info("Codex turn accepted", { threadId: req.body?.threadId, promptLength: prompt.length, attachmentCount: attachments.length });
|
||||||
|
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||||
|
try {
|
||||||
|
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||||
|
let turnId = "";
|
||||||
|
if (!threadId) {
|
||||||
|
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
|
||||||
|
threadId = String((thread as Record<string, unknown>).id || "");
|
||||||
|
setActiveThread(threadId, { emptyThread: true });
|
||||||
|
} else if (threadId !== workspace.activeThreadId) {
|
||||||
|
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||||
|
setActiveThread(threadId);
|
||||||
|
}
|
||||||
|
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
|
||||||
|
const chatMessage = {
|
||||||
|
sourceClientId: clientId,
|
||||||
|
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
|
||||||
|
};
|
||||||
|
let chatThreadId = "";
|
||||||
|
/** 将当前 turn 事件固定广播到实际线程。 */
|
||||||
|
const turnEmit = (type: string, payload: unknown) => {
|
||||||
|
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||||
|
session.emitThread(type, threadId, { ...data, ...(turnId ? { turn_id: turnId } : {}) });
|
||||||
|
};
|
||||||
|
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
|
||||||
|
threadId,
|
||||||
|
cwd: workspace.workspacePath,
|
||||||
|
permissionMode: permissionMode(req.body?.permissionMode),
|
||||||
|
appEmit: emit,
|
||||||
|
onStart: clientId ? () => session.bindClient(clientId) : undefined,
|
||||||
|
onThread: (actualThreadId) => {
|
||||||
|
if (actualThreadId !== threadId) {
|
||||||
|
threadId = actualThreadId;
|
||||||
|
setActiveThread(threadId, { emptyThread: true });
|
||||||
|
}
|
||||||
|
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||||
|
if (chatThreadId !== threadId) {
|
||||||
|
chatThreadId = threadId;
|
||||||
|
session.emitThread("chat_message", threadId, chatMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onTurn: (actualTurnId) => {
|
||||||
|
turnId = actualTurnId;
|
||||||
|
logger.info("Codex turn started", { threadId, turnId });
|
||||||
|
session.setCodexState({ busy: true, threadId, turnId });
|
||||||
|
},
|
||||||
|
onFinish: () => {
|
||||||
|
logger.info("Codex turn finished", { threadId, turnId });
|
||||||
|
session.clearTurnAttachments(clientId);
|
||||||
|
if (clientId) session.releaseClient(clientId);
|
||||||
|
session.setCodexState({ busy: false, threadId, turnId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.json({ ok: true, threadId });
|
||||||
|
} catch (error) {
|
||||||
|
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
app.post("/agent/codex/approval", route(async (req, res) => {
|
||||||
|
const decision = String(req.body?.decision || "");
|
||||||
|
if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" });
|
||||||
|
const ok = await resolveCodexApproval(String(req.body?.requestId || ""), decision);
|
||||||
|
res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "审批请求已失效" }) });
|
||||||
|
}));
|
||||||
|
app.post("/agent/codex/interrupt", route(async (req, res) => res.json({ ok: await interruptCodexTurn(String(req.body?.threadId || "")) })));
|
||||||
|
app.post("/agent/claude/turn", (req, res) => {
|
||||||
|
runClaudeTurn(String(req.body?.prompt || ""), emit);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
|
||||||
|
app.use((error: Error, req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
logger.error("HTTP request failed", { method: req.method, path: req.path, error });
|
||||||
|
res.status(500).json({ ok: false, error: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(port, "127.0.0.1", () => {
|
||||||
|
console.log("Infinite Canvas Agent");
|
||||||
|
console.log(`Local URL: ${config.url}`);
|
||||||
|
console.log(`Connect token: ${config.token}`);
|
||||||
|
console.log("Codex MCP is not installed by this command.");
|
||||||
|
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
|
||||||
|
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
|
||||||
|
if (logger.enabled) console.log(`Debug log: ${logger.filePath}`);
|
||||||
|
logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将异步 Express 路由异常交给统一错误处理中间件。 */
|
||||||
|
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 从 Express 路由参数中读取单个字符串。 */
|
||||||
|
function routeParam(value: string | string[]) {
|
||||||
|
return Array.isArray(value) ? value[0] || "" : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionMode(value: unknown): AgentPermissionMode {
|
||||||
|
return value === "automatic" || value === "full" ? value : "request";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 使用当前操作系统的文件管理器定位本地文件。 */
|
||||||
|
function revealLocalFile(filePath: string, isDirectory: boolean) {
|
||||||
|
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
|
||||||
|
const args = process.platform === "darwin"
|
||||||
|
? ["-R", filePath]
|
||||||
|
: process.platform === "win32"
|
||||||
|
? [isDirectory ? filePath : `/select,${filePath}`]
|
||||||
|
: [isDirectory ? filePath : path.dirname(filePath)];
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
||||||
|
child.once("spawn", () => {
|
||||||
|
child.unref();
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.once("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 结合服务配置解析当前请求 URL。 */
|
||||||
|
function requestUrl(req: Request, config: CanvasAgentConfig) {
|
||||||
|
return new URL(req.originalUrl || req.url || "/", config.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 设置跨域响应头并记录通过 token 授权的来源。 */
|
||||||
|
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
||||||
|
if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config") return true;
|
||||||
|
config.origins ||= [];
|
||||||
|
if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
|
||||||
|
config.origins.push(origin);
|
||||||
|
saveConfig(config);
|
||||||
|
}
|
||||||
|
res.setHeader("Vary", "Origin");
|
||||||
|
return config.origins.includes(origin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 校验请求查询参数或请求头中的连接 token。 */
|
||||||
|
function validToken(req: Request, url: URL, token: string) {
|
||||||
|
const header = req.headers["x-canvas-agent-token"];
|
||||||
|
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向 Agent 提示词追加本轮图片附件引用说明。 */
|
||||||
|
function withAttachmentContext(prompt: string, attachments: Array<{ id: string; name: string }>) {
|
||||||
|
if (!attachments.length) return prompt;
|
||||||
|
const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
|
||||||
|
return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
|
||||||
|
}
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
|
||||||
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "./config.js";
|
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "../canvas/schemas.js";
|
||||||
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "../config.js";
|
||||||
|
|
||||||
type CanvasAgentToolResponse = { ok?: boolean; result?: unknown; error?: string };
|
type CanvasAgentToolResponse = { ok?: boolean; result?: unknown; error?: string };
|
||||||
|
|
||||||
|
/** 启动通过标准输入输出通信的 MCP 服务。 */
|
||||||
export async function startMcpServer() {
|
export async function startMcpServer() {
|
||||||
const config = loadConfig(true);
|
const config = loadConfig(true);
|
||||||
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
|
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
|
||||||
@@ -13,6 +14,7 @@ export async function startMcpServer() {
|
|||||||
await server.connect(new StdioServerTransport());
|
await server.connect(new StdioServerTransport());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 向 MCP Server 注册单个 Canvas Agent 工具。 */
|
||||||
function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name: ToolName) {
|
function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name: ToolName) {
|
||||||
const schema = toolInputSchemas[name];
|
const schema = toolInputSchemas[name];
|
||||||
server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input: unknown) => {
|
server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input: unknown) => {
|
||||||
@@ -21,6 +23,7 @@ function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name:
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 将 MCP 工具调用转发到本地 Canvas Agent HTTP 服务。 */
|
||||||
async function postCanvasAgentTool(config: CanvasAgentConfig, name: ToolName, input: unknown) {
|
async function postCanvasAgentTool(config: CanvasAgentConfig, name: ToolName, input: unknown) {
|
||||||
const res = await fetch(`${config.url}/api/tools`, { method: "POST", headers: { "content-type": "application/json", "x-canvas-agent-token": config.token }, body: JSON.stringify({ name, input }) });
|
const res = await fetch(`${config.url}/api/tools`, { method: "POST", headers: { "content-type": "application/json", "x-canvas-agent-token": config.token }, body: JSON.stringify({ name, input }) });
|
||||||
const body = (await res.json()) as CanvasAgentToolResponse;
|
const body = (await res.json()) as CanvasAgentToolResponse;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** 将日期格式化为适合文件名使用的本地日期字符串。 */
|
||||||
|
export function formatDateForFilename(date = new Date()) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import {inspect} from "node:util";
|
||||||
|
|
||||||
|
import winston, {format, transports, type Logger as WinstonLogger} from "winston";
|
||||||
|
|
||||||
|
import {formatDateForFilename} from "./date.js";
|
||||||
|
|
||||||
|
/** 管理 Canvas Agent 的终端与文件 Debug 日志。 */
|
||||||
|
export class Logger {
|
||||||
|
readonly enabled = process.argv.includes("--debug");
|
||||||
|
readonly filePath = this.enabled ? path.join(os.homedir(), ".infinite-canvas", "logs", `canvas-agent-${formatDateForFilename()}.log`) : "";
|
||||||
|
private readonly logger: WinstonLogger | null;
|
||||||
|
|
||||||
|
/** 根据命令行 Debug 参数初始化日志输出。 */
|
||||||
|
constructor() {
|
||||||
|
if (!this.enabled) {
|
||||||
|
this.logger = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fs.mkdirSync(path.dirname(this.filePath), {recursive: true});
|
||||||
|
const line = format.printf(({level, message, timestamp, details}) => `${timestamp} ${level.toUpperCase()} ${message}${formatDetails(details)}`);
|
||||||
|
this.logger = winston.createLogger({
|
||||||
|
level: "debug",
|
||||||
|
transports: [
|
||||||
|
new transports.Console({format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
|
||||||
|
new transports.File({filename: this.filePath, format: format.combine(format.timestamp({format: "HH:mm:ss"}), line)}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出 Debug 级别日志。 */
|
||||||
|
debug(message: string, details?: unknown) {
|
||||||
|
if (details === undefined) this.logger?.debug(message);
|
||||||
|
else this.logger?.debug(message, {details: sanitize(details)});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出 Info 级别日志。 */
|
||||||
|
info(message: string, details?: unknown) {
|
||||||
|
if (details === undefined) this.logger?.info(message);
|
||||||
|
else this.logger?.info(message, {details: sanitize(details)});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出 Warn 级别日志。 */
|
||||||
|
warn(message: string, details?: unknown) {
|
||||||
|
if (details === undefined) this.logger?.warn(message);
|
||||||
|
else this.logger?.warn(message, {details: sanitize(details)});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 输出 Error 级别日志。 */
|
||||||
|
error(message: string, details?: unknown) {
|
||||||
|
if (details === undefined) this.logger?.error(message);
|
||||||
|
else this.logger?.error(message, {details: sanitize(details)});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将日志详情格式化为紧凑的单行文本。 */
|
||||||
|
function formatDetails(details: unknown) {
|
||||||
|
if (details === undefined) return "";
|
||||||
|
if (!details || typeof details !== "object" || Array.isArray(details)) return ` ${inspect(details, {depth: null, breakLength: Infinity})}`;
|
||||||
|
const text = Object.entries(details).filter(([, value]) => value !== undefined).map(([key, value]) => `${key}=${inspect(value, {depth: null, breakLength: Infinity})}`).join(" ");
|
||||||
|
return text ? ` ${text}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清理日志内容中的敏感数据和不可序列化引用。 */
|
||||||
|
function sanitize(value: unknown, key = "", seen = new WeakSet<object>()): unknown {
|
||||||
|
if (/token|authorization|api.?key|dataurl/i.test(key)) return "[REDACTED]";
|
||||||
|
if (typeof value === "string" && value.startsWith("data:")) return `[DATA URL ${value.length} chars]`;
|
||||||
|
if (value instanceof Error) return {name: value.name, message: value.message, stack: value.stack};
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
if (seen.has(value)) return "[CIRCULAR]";
|
||||||
|
seen.add(value);
|
||||||
|
if (Array.isArray(value)) return value.map((item) => sanitize(item, key, seen));
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([field, item]) => [field, sanitize(item, field, seen)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = new Logger();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type JsonRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
/** 安全读取未知对象中的指定字段。 */
|
||||||
|
export function field(value: unknown, key: string) {
|
||||||
|
return value && typeof value === "object" ? (value as JsonRecord)[key] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 将未知异常转换为可展示的错误信息。 */
|
||||||
|
export function errorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
@@ -10,5 +10,6 @@
|
|||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
|
||||||
|
# environment:
|
||||||
|
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
|
||||||
|
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ description: 当前画布节点的主要用途与操作流程
|
|||||||
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
||||||
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
||||||
- 输入内容可以手写,也可以从提示词库选择。
|
- 输入内容可以手写,也可以从提示词库选择。
|
||||||
|
- 输入框支持 `@` 引用已连接的图片、文本、视频、音频资源;`@` 引用图片时会在输入框内直接显示真实缩略图,发送时按当前连接自动编号交给模型。因此不需要再记忆「图片1 / 文本1」编号,画布节点右上角也不再显示资源角标。
|
||||||
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
||||||
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
|
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
|
||||||
|
|
||||||
|
|||||||
+1
-3
@@ -137,12 +137,10 @@ Codex 调 canvas_create_text_node
|
|||||||
|
|
||||||
## 工具确认和撤销
|
## 工具确认和撤销
|
||||||
|
|
||||||
为了避免 Codex 直接改动画布,浏览器侧对 `canvas_apply_ops` 默认有二次确认机制。收到写操作后,右侧面板会先展示待执行工具调用,用户确认后才真正应用到画布。
|
浏览器侧默认自动执行 `canvas_apply_ops` 写操作。需要逐次审核时,可在右侧对话输入框左下方把「自动确认」切换为「手动确认」;收到写操作后,对话中会展示待执行工具调用,用户确认后才真正应用到画布。
|
||||||
|
|
||||||
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
|
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
|
||||||
|
|
||||||
如果关闭工具确认,本地 Codex 可以直接执行画布工具调用,但仍然是通过同一套 `Canvas Agent -> SSE -> 浏览器执行` 的链路。
|
|
||||||
|
|
||||||
## 安全边界
|
## 安全边界
|
||||||
|
|
||||||
- `Canvas Agent` 默认只监听 `127.0.0.1`。
|
- `Canvas Agent` 默认只监听 `127.0.0.1`。
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
"defaultOpen": true,
|
"defaultOpen": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
"local-development",
|
"local-development",
|
||||||
"canvas-data-structure"
|
"canvas-data-structure",
|
||||||
|
"local-codex-canvas"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,42 @@ docker compose -f docker-compose.local.yml up -d --build
|
|||||||
## 数据说明
|
## 数据说明
|
||||||
|
|
||||||
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||||
|
|
||||||
|
## 统计分析(可选)
|
||||||
|
|
||||||
|
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
|
||||||
|
|
||||||
|
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
|
||||||
|
|
||||||
|
| 变量 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID(`G-XXXX`) |
|
||||||
|
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID(`hm.js?` 后面那串 hash) |
|
||||||
|
|
||||||
|
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
ANALYTICS_GA4_ID: G-XXXXXXXXXX
|
||||||
|
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
|
||||||
|
```
|
||||||
|
|
||||||
|
或使用 `docker run`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 3000:3000 \
|
||||||
|
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
|
||||||
|
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
|
||||||
|
ghcr.io/basketikun/infinite-canvas:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
|
||||||
|
|
||||||
|
<Callout type="warn">
|
||||||
|
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
|
||||||
|
</Callout>
|
||||||
|
|||||||
@@ -85,9 +85,12 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
|||||||
- 生成数量。
|
- 生成数量。
|
||||||
- 视频模型。
|
- 视频模型。
|
||||||
- 视频比例、清晰度、时长、生成声音和水印。
|
- 视频比例、清晰度、时长、生成声音和水印。
|
||||||
|
- 自定义生图 / 视频接口的调用方式,灵活适配不同中转站与自建服务。
|
||||||
|
|
||||||
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
||||||
|
|
||||||
|
节点下方对话框和组装提示词输入框都支持 `@` 引用已连接的图片、文本、视频、音频等资源;`@` 引用图片时,输入框内会直接显示该图片的真实缩略图,而不再是「图片1」这类文字编号,发送时会按当前连接自动编号交给模型理解。由于引用改在对话框内直接 `@`,画布节点右上角不再显示「图片1 / 文本1」资源角标。
|
||||||
|
|
||||||
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
||||||
|
|
||||||
## 画布助手
|
## 画布助手
|
||||||
@@ -106,6 +109,18 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
|||||||
- 把助手生成的图片插入画布。
|
- 把助手生成的图片插入画布。
|
||||||
- 折叠和展开助手面板。
|
- 折叠和展开助手面板。
|
||||||
|
|
||||||
|
## 节点插件系统
|
||||||
|
|
||||||
|
画布支持通过节点插件扩展新的节点类型,内置节点之外的能力都可以以插件形式加载:
|
||||||
|
|
||||||
|
- 支持通过 URL 动态安装、启用、更新和卸载远程节点插件。
|
||||||
|
- 提供官方插件注册表,节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||||
|
- 提供 TypeScript 插件开发 SDK,可自行开发画布节点插件,并使用 AI 生成、面板控制等扩展能力。
|
||||||
|
- 内置 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||||
|
- 插件面板会显示插件版本,并对可升级的插件给出提醒。
|
||||||
|
|
||||||
|
Codex App 插件的安装与使用见 [Codex App 插件](/docs/overview/codex-app-plugin)。
|
||||||
|
|
||||||
## 提示词库
|
## 提示词库
|
||||||
|
|
||||||
前台提示词库支持:
|
前台提示词库支持:
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
"render",
|
"render",
|
||||||
"docker",
|
"docker",
|
||||||
"third-party-prompt-repositories",
|
"third-party-prompt-repositories",
|
||||||
"[在线体验](https://canvas.best/)",
|
"[在线体验](https://canvas.best/)"
|
||||||
"local-codex-canvas"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ description: 当前已接入同步逻辑的第三方提示词仓库
|
|||||||
|
|
||||||
| 地址 | 状态 |
|
| 地址 | 状态 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 |
|
|
||||||
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
|
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
|
||||||
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
||||||
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"[更新日志](/docs/progress/changelog)",
|
"[更新日志](/docs/progress/changelog)",
|
||||||
"local-agent-integration-plan",
|
"local-agent-integration-plan",
|
||||||
|
"prompt-chip-input-plan",
|
||||||
"pending-test",
|
"pending-test",
|
||||||
"todo"
|
"todo"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,41 +5,44 @@ description: 当前版本已实现但仍需人工验证的变更项
|
|||||||
|
|
||||||
# 待测试
|
# 待测试
|
||||||
|
|
||||||
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
|
- Agent 画布生图:重启 Canvas Agent 后,其自动生成的工作区 `AGENTS.md` 应同步为最新版指令(用户自行编写的其他 `AGENTS.md` 不应被覆盖);输入“生成一张图片”时应调用 `canvas_generate_image`,通过当前画布节点创建并运行生成流程;只有明确输入“使用 Codex 内置生图/ImageGen 技能生成”时才允许调用内置能力,Codex App Server 返回的 `imageGeneration` 结果应显示在对话中并作为保持原比例的图片节点添加到当前画布;Agent 不应在没有生成结果时提前声称“已生成”。
|
||||||
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
|
- Agent 顶部栏:标题只显示垂直居中的「Agent」,连接、对话、历史、日志、新对话和收起操作应位于同一行;面板较窄时标签仍可横向滚动且操作不应错位。
|
||||||
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
|
- Agent Markdown 样式:右侧 Agent 回复中的代码块应为横向占满消息区域、无语言标题和无双层边框的紧凑代码条,单行内容不应保留纵向大面积空白,复制操作仅在悬停时弱化显示;行内代码、链接在浅色和深色主题下应清晰;点击外部链接后应显示中文紧凑确认弹窗,长路径能够正常换行且复制、继续打开和关闭操作可用;点击 `/Users/`、`/home/` 等本地绝对文件路径时应改为提示在系统文件管理器中定位,支持复制路径,且浏览器地址不应跳转为 localhost 文件路径。
|
||||||
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
|
- Agent 工具确认模式:右侧面板标题栏不应再显示「工具确认」开关;对话输入框左下方应显示确认模式选择,默认选中「自动确认」,画布写入工具应直接执行;切换为「手动确认」后,画布写入工具应在对话中展示等待确认卡片,并支持批准或拒绝。
|
||||||
- 全站 Agent:新增 `site_navigate` 工具,Agent 可直接跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`;导航直接执行不走工具确认,需验证跳转正确且面板不消失。
|
- Canvas Agent Codex 升级:启动 Canvas Agent 后应能正常连接 Codex、创建或恢复会话、发送消息并调用画布工具,实际运行的 Codex CLI 版本应为 0.145.0;运行中停止任务应只中断当前 turn,随后无需重启 Agent 即可继续发送新任务。
|
||||||
- 全站 Agent:画布工具(canvas_apply_ops 等)仅在当前处于画布页时可用,不在画布页调用会返回“当前不在画布页,请先用 site_navigate 打开画布”,需验证 Agent 会先导航到画布再执行画布操作。
|
- Canvas Agent Debug:使用 `npx -y @basketikun/canvas-agent --debug` 启动后,终端应按“时间 级别 消息 详情”的纯文本单行格式输出日志并显示日志文件路径,`~/.infinite-canvas/logs/` 下应按启动日期生成相同格式的 `canvas-agent-YYYY-MM-DD.log`,同一天多次启动应追加到同一文件;普通启动保持原有简洁输出,日志中不应出现连接 token 或图片 Data URL 原文。
|
||||||
- 全站 Agent:Codex 会话改为单一站点级连续线程(不再按 canvasId 分),跨页面/跨画布保持同一条对话上下文,历史记录即这条线程;本地 Agent workspace 从按画布分改为固定 `site` workspace(`~/.infinite-canvas/codex-workspaces/site`),已装 `@basketikun/canvas-agent` 的用户需更新到新版本才生效。
|
- Agent HTTP 诊断日志:网页发送一条普通消息后,本地 Debug 日志不应重复输出 `/health`、`/canvas/state`、`/canvas/activate` 成功请求、流式增量或完整会话响应,只保留 HTTP 请求与 Codex 生命周期摘要;右侧「日志」应以单行时间线展示发送、开始、回复、工具、完成用量和错误,不再输出 userMessage started/completed、流式摘要、重复 threadId 或大段原始 JSON。
|
||||||
- 全站 Agent:移除仅前端调用 OpenAI responses 接口的在线画布助手死代码(含 `requestToolResponse`),Agent 统一走 MCP + 本地 Codex 链路。
|
- Agent 对话统计:用户消息应右对齐并使用透明无气泡的简洁排版,用户和 Codex 两侧均不显示人物头像,消息下方均不显示时间或 Token 信息;输入框上方应居中展示最新一次模型调用的输入、缓存、输出 Token 用量,不显示会话累计值,数值更新时应从旧值平滑滚动到新值而非突然跳变,新建、切换或删除当前会话后应清空旧统计。
|
||||||
- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段。
|
- Agent 回复实时显示:在右侧 Agent 发送消息后,用户消息下方应立即出现“正在思考...”,任务运行期间不应闪退;工具完成后应显示 Codex 正在继续处理及已等待时长,等待超过 30 秒时提示可继续等待或停止本轮;Codex 的回复应在当前对话中持续显示,实时事件缺失时也应在任务完成后自动同步完整内容,无需切换到历史或日志再返回对话。模型繁忙等任务失败时应立即结束等待状态,在对话中显示中文错误原因和重试建议,诊断日志不应再把失败轮次记为“处理完成”,刷新历史后错误仍应保留。新建尚未发送首条消息的空会话不应反复出现历史读取失败。
|
||||||
- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。
|
- Agent 流式交互性能:发送长回复时文字应连续平滑出现,输入框、滚动和画布操作不应随回复变长而明显卡顿;长历史会话中只有当前流式消息持续更新,屏幕外消息不应造成明显布局压力;任务完成后应通过 SSE 自动同步完整历史,不再持续请求 `/health` 轮询状态。
|
||||||
- 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组。
|
- Agent 过程时间线:新建 Agent 对话后 Codex 应生成可读思考摘要,并在运行时依次显示中文的思考摘要、执行计划、命令执行、网页搜索、文件修改和画布工具活动;思考摘要的图标、标题和箭头应稳定保持在同一行,默认收起且无边框,点击箭头后才展开 Codex 实际返回的具体摘要,其中 Markdown 强调、列表和代码应正确渲染而非显示原始符号,完成事件不应再用「已完成分析」覆盖已经收到的摘要;命令执行也应使用无边框紧凑行,显示「已执行 1 条命令」和单行命令预览,点击后再展开工作目录、耗时、退出状态和运行输出;其他工具调用继续使用紧凑卡片排版,状态图标、标题和状态文字保持在同一行;结构化任务进度不应混在对话时间线中,而应独立放在对话区下方、Token 统计上方,支持展开和折叠,并逐项实时更新「待处理」「进行中」「已完成」状态;新任务生成时默认展开,任务结束后保留最新结果,同一计划更新不应生成重复内容;文件详情展示文件路径与新增/修改/删除动作,工具详情不应出现请求 ID、英文工具名或原始 JSON;一轮对话结束自动同步历史以及刷新页面后从历史中 resume 对话时,思考摘要、命令和工具过程记录仍应完整保留并保持相同展示。
|
||||||
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
|
- Agent 权限控制:输入框可选择「请求批准」「自动审查」「完全访问」;请求批准模式下,Codex 编辑工作区外文件、执行受限命令或访问网络时应在对话中显示审批卡片,支持拒绝、允许一次和本会话允许,并能依次处理多个并发请求;自动审查模式应仅将需要用户决定的风险操作送入审批;完全访问必须先显示风险确认,启用后可访问网络和本机文件且不再请求审批;选择应在刷新后保留。
|
||||||
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
|
- Agent 历史记录:点击记录卡片应直接进入对应对话,不再显示「进入」按钮;可勾选单条或全选多条记录并批量删除,删除当前对话后聊天内容应清空。
|
||||||
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
|
- Agent 默认新对话:每次进入任一画布并连接 Agent 后,对话区应保持空白且不自动恢复上一次会话;第一次发送消息时才创建新线程,不应产生未发送消息的空历史记录;需要继续旧对话时可在「历史」中主动选择恢复。
|
||||||
- Codex App 插件:精简插件 README 安装步骤,更新插件网站和作者信息,并将 `open-canvas` 调整为默认打开在线画布、仅按需启动本地前端;需重新安装后确认详情页和两种打开流程正确。
|
- Agent 当前画布优先:在已打开某个画布时要求 Agent 创建、修改、整理或生成内容,Agent 应直接读取并操作当前画布,不应先调用 `canvas_list_projects` 或使用 `site_navigate` 重复进入画布;只有明确要求查看或切换其他画布时才允许查询画布列表并导航。
|
||||||
- 本地 Codex 连接画布文档:新增 Canvas Agent、MCP、SSE 与浏览器画布交互原理说明,需确认文档导航和描述是否清晰。
|
- Agent 图片消息:发送一张或多张图片附件后,图片应紧跟用户文字并在消息右侧显示为约 40px 的紧凑缩略图,不再撑大消息区域,单击缩略图应打开大图预览,打开或关闭预览不应改变消息间距或产生额外空行;任务运行中、完成同步历史、切换页面及重新进入历史对话后都应显示浏览器本地保存的图片缩略图,不应消失或把 attachmentId、附件使用说明等内部上下文回显到用户消息中;删除历史会话后应同步清理对应缩略图。
|
||||||
- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。
|
- 画布文本复制:在 Agent 对话或节点信息详情中用鼠标选中文字后,按 `Ctrl/Cmd + C` 应复制所选文本,不应触发画布节点复制;未选中文字且焦点位于画布时,原有节点复制快捷键应保持可用。
|
||||||
- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。
|
- Agent 工作目录指令:`canvas-agent/agent-instructions.md` 应作为独立维护源;重启 Canvas Agent 后,当前工作目录应自动生成 `AGENTS.md`;新建对话发送消息时,Codex 日志中的用户消息只包含本轮请求和必要的附件上下文,不再重复整段 Infinite Canvas 前置提示词,画布及工作台工具仍可正常调用。
|
||||||
- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。
|
- 画布文本设置:文本节点和生成配置节点切换到文本模式后应显示推理强度设置,可选择自动、低、中、高、极高;选择自动时默认 OpenAI Responses 请求不应携带 `reasoning`,选择其他档位时应携带所选强度,刷新画布后节点设置应保留;文本模型自定义调用脚本应能读取 `reasoningEffort`,OpenAI 模板应按自动或指定档位正确组装请求。
|
||||||
- 画布 Agent:Codex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`。
|
- 生图工作台参考图:将一张或多张图片拖入参考图区域后应直接上传并显示缩略图;拖入非图片文件应忽略,拖动过程中区域应显示高亮提示,放下文件不应导致浏览器打开或替换当前页面。
|
||||||
- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话。
|
- 视频创作台参考资产:将图片、MP4/MOV 视频或 MP3/WAV 音频拖入任一参考资产区域后,应按文件类型自动上传到对应列表;当前拖入区域应显示高亮提示,原有数量、大小、时长和格式限制应继续生效。
|
||||||
- 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent。
|
- 画布组装提示词:输入或连接超长提示词后,浮层正文高度不应继续撑大,内容应可在正文区域内滚动查看和编辑,标题及关闭按钮保持可见。
|
||||||
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。
|
- 画布节点提示词:图片等节点下方的提示词超过输入区域高度后,鼠标悬停在输入区域内滚动应查看提示词内容,不应缩放画布。
|
||||||
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。
|
- 画布节点提示词回显:单图或多图生成完成后,切换选择生成结果的根节点及任一子节点,下方输入区域都应继续显示生成时的提示词;切换到其他节点再选回来后也不应清空。
|
||||||
- Gemini 生图:生图工作台和画布生图会把尺寸配置转换为 Gemini `responseFormat.image.aspectRatio` 传入,需验证 1:1、9:16、16:9 及自定义像素尺寸的输出比例。
|
- 画布生成配置:连接文本节点后连续点击生成(包含失败重试),组装提示词不应重复追加上游文本;在生图、文本、视频、音频之间切换时,实际请求应始终使用当前面板展示的对应类型模型。
|
||||||
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。
|
- 画布左侧元素列表:点击元素整行应平滑定位并选中对应节点;有内容的图片元素应显示预览按钮,点击后打开大图弹窗且不触发画布定位。
|
||||||
- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务。
|
- 配置与用户偏好:导出 JSON 后应包含渠道、默认模型、生成偏好、提示词来源和 WebDAV 配置;在修改当前配置后重新导入该文件,应恢复导出时的设置,错误 JSON 文件应提示格式不正确。配置文件包含 API Key 和 WebDAV 凭据,不应公开分享。
|
||||||
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。
|
- 模型渠道协议:渠道编辑可选择「火山方舟」并自动填入方舟接口地址;任意名称的生图模型应按方舟 JSON 格式提交参考图,任意名称的视频模型应按方舟任务格式提交和查询,不再依赖模型名包含 `doubao`、`seedream` 或 `seedance`;1080p 不应再因模型名包含 `fast` 被禁用,参考视频应允许最大 200MB、总像素 409600-8295044,并继续校验官方宽高、比例和时长限制。
|
||||||
- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源。
|
- 图片编辑弹窗:遮罩、切图和裁剪连续滚轮缩放时,图片与遮罩应保持同步且不再闪烁、短暂消失或跳动;遮罩画笔圆心应始终固定在鼠标位置,仅直径随缩放变化,缩放后仍可准确涂抹、拖动切分线和调整裁剪框。
|
||||||
- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转。
|
- 提示词中心布局:页面标题及提示词总数应居中;连续输入搜索文字时应在停止输入约 300ms 后再查询;桌面端分类与标签应在左侧独立滚动,右侧搜索框下直接展示提示词卡片;标签数量较多时不能继续向下挤压提示词,窄屏下应恢复上下排列且内容不溢出;不再显示「我的提示词」Tab,收藏提示词应直接加入我的资产。
|
||||||
- 前端构建:修复 TypeScript 严格检查下的构建报错。
|
- 提示词详情弹窗:封面和参考图应固定显示在上方,复制及加入资产操作栏固定在底部,只有中间的标签、描述及提示词内容区域可以滚动;弹窗宽高应受视口限制且不超出屏幕。
|
||||||
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。
|
- 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。
|
||||||
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
|
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
|
||||||
- 环境变量:移除过期的后端 `.env.example` 示例文件。
|
- 画布提示词库:不应再显示「我的提示词」分组;不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
|
||||||
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
|
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
||||||
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
|
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。
|
||||||
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
|
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
|
||||||
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
|
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
|
||||||
|
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
|
||||||
|
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示向下箭头且新消息不强制打断阅读,点击箭头后平滑回到底部并继续跟随新消息。
|
||||||
|
- Agent 消息区分:用户消息应在右侧使用透明无气泡排版,AI 回复应在左侧使用无头像的开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
---
|
||||||
|
title: 提示词面板引用显示图片缩略图规划
|
||||||
|
description: 将提示词面板输入框从 textarea 改造为 contentEditable,让 @ 引用直接显示真实图片缩略图
|
||||||
|
---
|
||||||
|
|
||||||
|
# 提示词面板 @ 引用显示真实图片缩略图
|
||||||
|
|
||||||
|
## 背景 / 要解决的问题
|
||||||
|
|
||||||
|
提示词面板(`CanvasNodePromptPanel`)的输入框里,`@` 引用一张图片后,显示的是蓝色文字 chip「图片1」。期望**直接显示这张图片的真实缩略图**,而不是文字编号。
|
||||||
|
|
||||||
|
当前该输入框是原生 `<textarea>`,只能承载纯文本,无法内嵌图片。要显示缩略图,必须改用 `contentEditable`(可编辑 div),把引用做成带 `<img>` 的原子小卡片。项目里「组装提示词」组件(`CanvasConfigComposer`)已经是这套 contentEditable + 图片 chip 的成熟实现,可作蓝本。
|
||||||
|
|
||||||
|
## 关键约束(为什么不能直接改现有组件)
|
||||||
|
|
||||||
|
`CanvasResourceMentionTextarea` 被两处复用:
|
||||||
|
|
||||||
|
1. **提示词面板** `web/src/components/canvas/canvas-node-prompt-panel.tsx` —— 本次要改的对象。`highlightLabels` 默认 true,value 是提示词字符串,引用以 label 文本「图片1」嵌在其中,**直接发给 AI**(配合 `web/src/lib/image-reference-prompt.ts` 的「参考图片编号:图片1」前缀)。
|
||||||
|
2. **节点内文本编辑** `web/src/components/canvas/canvas-node.tsx`(`isEditingContent` 分支)—— `highlightLabels={false}`,通过 `ref` 调用 `textarea.setSelectionRange(...)` 等 **textarea 专有 API**。若把组件改成 contentEditable,这处会直接崩。
|
||||||
|
|
||||||
|
> 结论:**新建一个 contentEditable chip 输入组件,只替换提示词面板那一处**;`CanvasResourceMentionTextarea`(textarea 版)保持不动,继续服务节点内文本编辑。
|
||||||
|
|
||||||
|
## 方案:新增 `CanvasPromptChipInput` 组件
|
||||||
|
|
||||||
|
新文件 `web/src/components/canvas/canvas-prompt-chip-input.tsx`,参照 `canvas-config-composer.tsx` 的 contentEditable 模式,但有一处**核心差异**:
|
||||||
|
|
||||||
|
- 「组装提示词」把引用序列化成 `@[node:UUID]` 标记;
|
||||||
|
- 本组件的 value 必须保持**引用的 label 文本**(如「图片1」),这样发给生成的提示词语义不变。因此 chip ↔ 文本的序列化用 **label 文本**,不是 node 标记。
|
||||||
|
|
||||||
|
### 组件契约(与现用法一致)
|
||||||
|
|
||||||
|
Props:`value: string`、`references: CanvasResourceReference[]`、`onChange(value)`、`onSubmit?()`、`className`、`style`、`placeholder`。图片缩略图取 `reference.previewUrl`(即 `node.metadata.content`,已存在于 `CanvasResourceReference`)。
|
||||||
|
|
||||||
|
### 实现要点(大量复用 composer 的做法)
|
||||||
|
|
||||||
|
1. **DOM ← value(未聚焦时重建)**:仿 `CanvasConfigComposer` 的重建 effect。用 active label 列表(`references.filter(active).map(label)`,按长度降序)把 value 字符串切成「文本片段 + 命中 label」,命中处插入 chip。聚焦时不重建(`document.activeElement === editor` 直接 return),避免与输入 / IME 打架。
|
||||||
|
2. **chip 渲染**:仿 `createReferenceChip`。image 类型 → `size-6` 的 `<img src={reference.previewUrl}>`;其它类型 → 截断文字。chip 为 `contentEditable="false"` 的 `<span>`,用 `dataset.refLabel` 存 label 以便序列化。
|
||||||
|
3. **value ← DOM(syncFromEditor)**:仿 `serializeNodes`,但 chip 序列化为 `dataset.refLabel`(label 文本)而非 node 标记;文本节点原样拼接,`<br>` → `\n`。
|
||||||
|
4. **@ 菜单**:复用 `textBeforeCaret` + `/@([^\s@]*)$/` 检测;菜单定位改用 `getSelection().getRangeAt(0).getBoundingClientRect()`(比 textarea 的镜像 div 更简单、天然贴着光标)。菜单 UI 沿用现有 `MentionMenu` 的视觉(缩略图 + label + 副标题)。
|
||||||
|
5. **原子删除**:直接复用 composer 的 `deleteAdjacentReference` / `adjacentReferenceNode` / `findReferenceSibling`——contentEditable + `contentEditable="false"` chip 天然整体删除。可把这几个通用 DOM 助手连同 `textBeforeCaret` / `placeCaretAtEnd` / `closestEditor` 复制进新组件(保持 composer 不动,规避回归风险)。
|
||||||
|
6. **回车发送 + IME**:复用 `isPlainEnterKey` / `isImeComposing`(`@/lib/keyboard-event`)与 `onCompositionStart/End`,发送时调用 `onSubmit`。
|
||||||
|
7. **占位符 & 样式**:空值时显示 placeholder(absolute 占位层);容器套用面板传入的 `className/style`(`min-h-40`、透明底、无边框,与当前一致)。
|
||||||
|
|
||||||
|
### 替换点
|
||||||
|
|
||||||
|
`canvas-node-prompt-panel.tsx` 里把 `<CanvasResourceMentionTextarea .../>` 换成 `<CanvasPromptChipInput .../>`(同样的 value/references/onChange/onSubmit/className/style/placeholder)。其余(模型选择、设置、生成按钮)不动。
|
||||||
|
|
||||||
|
### 不改动
|
||||||
|
|
||||||
|
- `CanvasResourceMentionTextarea`(textarea 版)保留,节点内文本编辑继续用它。
|
||||||
|
- 生成链路、`image-reference-prompt.ts` 前缀逻辑不变(value 仍是 label 文本)。
|
||||||
|
|
||||||
|
## 影响文件
|
||||||
|
|
||||||
|
- 新增:`web/src/components/canvas/canvas-prompt-chip-input.tsx`
|
||||||
|
- 修改:`web/src/components/canvas/canvas-node-prompt-panel.tsx`(仅替换输入框组件 + 相应 import)
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
1. `npx tsc --noEmit` 通过。
|
||||||
|
2. dev server 刷新后:
|
||||||
|
- 连一张图片到图片 / 视频节点 → 面板输入框输入 `@` → 菜单贴着光标弹出 → 选中 → **输入框内显示该图的真实缩略图 chip**(不再是「图片1」文字)。
|
||||||
|
- 光标在 chip 后按一次 Backspace → 整个 chip 一次删除。
|
||||||
|
- 普通文字 + chip 混排,回车触发生成;确认发给生成的 prompt 里引用仍是「图片1」文本(生成结果正常引用到图)。
|
||||||
|
- 中文输入法输入不吞字、不误触发发送。
|
||||||
|
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
---
|
|
||||||
title: 打赏支持
|
|
||||||
description: 支持无限画布项目继续维护
|
|
||||||
---
|
|
||||||
|
|
||||||
# 打赏支持
|
|
||||||
|
|
||||||
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
|
|
||||||
|
|
||||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
|
||||||
|
|
||||||
## 支持方式
|
|
||||||
|
|
||||||
支持方式包括但不限于:
|
|
||||||
|
|
||||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
|
||||||
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
|
|
||||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
|
||||||
|
|
||||||
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
|
|
||||||
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
|
|
||||||
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
|
|
||||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
@@ -4,7 +4,6 @@
|
|||||||
"defaultOpen": true,
|
"defaultOpen": true,
|
||||||
"pages": [
|
"pages": [
|
||||||
"security",
|
"security",
|
||||||
"donate",
|
|
||||||
"sponsor"
|
"sponsor"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
---
|
---
|
||||||
title: 广告赞助
|
title: 赞助支持
|
||||||
description: 广告赞助、项目露出和社区推广说明
|
description: 赞助支持、广告合作、项目露出和社区推广说明
|
||||||
---
|
---
|
||||||
|
|
||||||
# 广告赞助
|
# 赞助支持
|
||||||
|
|
||||||
|
如果本项目对你有帮助,欢迎通过 Star、广告赞助合作、提供 AI 订阅账号等方式支持项目继续维护。
|
||||||
|
|
||||||
|
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。相关支持会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||||
|
|
||||||
|
## 支持方式
|
||||||
|
|
||||||
|
支持方式包括但不限于:
|
||||||
|
|
||||||
|
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||||
|
- 通过广告赞助合作支持项目维护成本。
|
||||||
|
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||||
|
|
||||||
|
## 广告赞助
|
||||||
|
|
||||||
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
||||||
|
|
||||||
## 适合的赞助内容
|
### 适合的赞助内容
|
||||||
|
|
||||||
- AI 工具、模型服务、API 服务或开发者工具。
|
- AI 工具、模型服务、API 服务或开发者工具。
|
||||||
- 与图片、视频、文本生成工作流相关的产品。
|
- 与图片、视频、文本生成工作流相关的产品。
|
||||||
- 适合开源项目用户和 AI 创作者使用的服务。
|
- 适合开源项目用户和 AI 创作者使用的服务。
|
||||||
|
|
||||||
## 建议提供的信息
|
### 建议提供的信息
|
||||||
|
|
||||||
- 品牌或产品名称。
|
- 品牌或产品名称。
|
||||||
- 希望展示的位置和周期。
|
- 希望展示的位置和周期。
|
||||||
|
|||||||
+2
-3
@@ -27,8 +27,7 @@
|
|||||||
## 支持与安全
|
## 支持与安全
|
||||||
|
|
||||||
- [漏洞提交](/docs/support/security)
|
- [漏洞提交](/docs/support/security)
|
||||||
- [打赏支持](/docs/support/donate)
|
- [赞助支持](/docs/support/sponsor)
|
||||||
- [广告赞助](/docs/support/sponsor)
|
|
||||||
|
|
||||||
## 项目进度
|
## 项目进度
|
||||||
|
|
||||||
@@ -43,4 +42,4 @@
|
|||||||
|
|
||||||
## 原理说明
|
## 原理说明
|
||||||
|
|
||||||
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
|
- [本地 Codex 连接画布原理](/docs/development/local-codex-canvas)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const tabs = [
|
|||||||
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
|
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
|
||||||
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
||||||
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
||||||
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
|
{ title: '赞助支持', href: '/docs/support/sponsor', prefix: '/docs/support' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function DocsTopTabs() {
|
export function DocsTopTabs() {
|
||||||
|
|||||||
@@ -7,4 +7,9 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
|
||||||
|
location = /config.js {
|
||||||
|
add_header Cache-Control "no-store";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
# Infinite Canvas 画布节点插件
|
||||||
|
|
||||||
|
给画布扩展自定义节点。每个插件是一个**独立目录**,用 **TypeScript** 编写,自带 `package.json` / `build.mjs` / `src/index.tsx` / `dist/`,互不耦合,可单独构建、发布、升级。
|
||||||
|
|
||||||
|
内置节点只有文本、图片、视频、音频、生成配置、组六种;其余节点(Markdown、SVG、HTML、3D 全景、便利贴……)都是插件。
|
||||||
|
|
||||||
|
作者只写节点 UI 与逻辑,**类型、JSX、宿主 React、构建全部由 [`@infinite-canvas/plugin-sdk`](./sdk/README.md) 提供**,写 TSX 全程有代码提示;产物仍是宿主加载器现有契约的 ESM(React external、宿主单例)。
|
||||||
|
|
||||||
|
## 目录约定
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/canvas/
|
||||||
|
sdk/ # 插件 SDK(类型 + automatic JSX 运行时 + 构建助手)
|
||||||
|
template/ # 起步模板:复制它开始写新插件
|
||||||
|
markdown/ # 每个插件一个独立目录
|
||||||
|
package.json
|
||||||
|
build.mjs # 一行 buildPlugin,产物名取目录名 → dist/markdown.js
|
||||||
|
tsconfig.json
|
||||||
|
src/index.tsx # 插件源码(默认导出 definePlugin(...))
|
||||||
|
README.md
|
||||||
|
svg/ html/ panorama/ sticky-note/ ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r plugins/canvas/template plugins/canvas/my-plugin
|
||||||
|
cd plugins/canvas/my-plugin
|
||||||
|
# 改 package.json 的 name;改 src/index.tsx 里的 id / nodes[].type
|
||||||
|
npm install
|
||||||
|
npm run dev # watch 构建,产物同步到 web/public/plugins/my-plugin.js
|
||||||
|
npm run typecheck # tsc --noEmit,类型自检
|
||||||
|
```
|
||||||
|
|
||||||
|
产物名取**目录名**,复制后记得把目录改成插件名。
|
||||||
|
|
||||||
|
## 构建 / 发布 / 升级
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/canvas/<name>
|
||||||
|
npm install
|
||||||
|
npm run build # → dist/<name>.js,并同步到 web/public/plugins/<name>.js
|
||||||
|
npm run dev # watch,改动自动构建并同步
|
||||||
|
```
|
||||||
|
|
||||||
|
把 `dist/<name>.js` 托管到任意静态地址(CDN、GitHub Raw、对象存储),用户在画布「节点插件」管理器填该 URL 安装。升级时重新构建覆盖同一 URL,用户点「更新」即可。
|
||||||
|
|
||||||
|
## 官方插件注册表
|
||||||
|
|
||||||
|
本项目官方插件由 CI 集中构建后发布到孤儿分支 `plugins-dist`(**构建产物不进 git**),画布「节点插件」面板顶部的**官方插件**区经 jsDelivr 从该分支远程拉取并一键安装;第三方插件仍走下方「第三方插件」的 JS URL 安装。构建脚本与发布说明见 [`registry/`](./registry/README.md);清单地址可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖成自建来源。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
`npm run dev` 起 watch,产物会同步到 `web/public/plugins/<name>.js`。此后有两种方式在画布里用到它:
|
||||||
|
|
||||||
|
**方式一(推荐):自动发现。** 画布启动时会扫描 `web/public/plugins/` 下的插件,自动加入「节点插件」管理器列表,**默认关闭**;打开开关即启用。无需手动填 URL,启用时会按文件重新拉取,配合 watch 改完刷新即最新。
|
||||||
|
|
||||||
|
**方式二:`VITE_DEV_PLUGINS`。** 在 `web/.env.local` 声明(逗号分隔多个),这些插件每次刷新页面都**重新拉取并直接激活**(不缓存、不落库、无开关):
|
||||||
|
|
||||||
|
```env
|
||||||
|
VITE_DEV_PLUGINS=/plugins/markdown.js,/plugins/svg.js
|
||||||
|
```
|
||||||
|
|
||||||
|
再起画布 `web`(`npm run dev`)。流程即:改 `src/index.tsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。
|
||||||
|
|
||||||
|
## 用 SDK 写插件
|
||||||
|
|
||||||
|
默认导出 `definePlugin({...})`(对象形式,**无需再 `const { React } = runtime`**):
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
function Content({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [n, setN] = useState(0);
|
||||||
|
return (
|
||||||
|
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||||
|
{ctx.node.title}: {n}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "my-plugin",
|
||||||
|
name: "我的插件",
|
||||||
|
version: "1.0.0",
|
||||||
|
css: "…", // 可选:插件样式,自动注入/清理
|
||||||
|
nodes: [ /* CanvasNodeDefinition[] */ ],
|
||||||
|
setup(app) { return () => {}; }, // 可选,返回清理函数;app 含 injectCSS/emit/on
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
SDK 导出的 hooks(`useState/useEffect/useMemo/useRef/...`)运行时转发宿主 React,类型来自 `@types/react`。SDK 与依赖接入见 [`sdk/README.md`](./sdk/README.md)。
|
||||||
|
|
||||||
|
### CanvasNodeDefinition
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||||
|
title: string; // 创建菜单/默认标题
|
||||||
|
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||||
|
description?: string;
|
||||||
|
defaultSize: { width, height };
|
||||||
|
defaultMetadata?: object; // 新建节点初始 metadata(文本内容放 content)
|
||||||
|
minimapColor?: string;
|
||||||
|
showInCreateMenu?: boolean; // 默认 true
|
||||||
|
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||||
|
keepAspectRatio?: (node) => boolean;
|
||||||
|
resource?: (node) => { kind: "text"|"image"|"video"|"audio", text?, url? } | null; // 作为上游输入被消费时输出什么
|
||||||
|
Content: ({ ctx }) => ReactNode; // 节点主体渲染
|
||||||
|
Panel?: ({ ctx, onClose }) => ReactNode; // 可选:节点下方面板
|
||||||
|
toolbar?: (ctx) => Array<{ id, title, label, icon, onClick, danger? }>; // 追加到 hover 工具栏
|
||||||
|
onDoubleClick?: (ctx) => boolean; // 返回 true 表示已处理双击
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### ctx:节点与画布交互接口
|
||||||
|
|
||||||
|
`Content` / `Panel` / `toolbar` 都会拿到 `ctx`(类型 `CanvasNodeContext`):
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ctx.node` | 当前节点数据(含 `metadata.content` 等) |
|
||||||
|
| `ctx.theme` / `ctx.scale` | 当前画布主题 token 与缩放,用来让 UI 跟随主题 |
|
||||||
|
| `ctx.updateMetadata(patch)` | 更新自身 metadata(如保存内容) |
|
||||||
|
| `ctx.updateNode(patch)` | 更新自身 title/width/height |
|
||||||
|
| `ctx.getNode(id)` / `ctx.getNodes()` / `ctx.getConnections()` | 读画布 |
|
||||||
|
| `ctx.getUpstream()` / `ctx.getDownstream()` | 取上/下游相连节点 |
|
||||||
|
| `ctx.applyOps(ops)` | 用画布指令集增删节点/连线、选择、触发生成(见下) |
|
||||||
|
| `ctx.emit(event, payload)` / `ctx.on(event, handler)` | 节点/插件间事件通信 |
|
||||||
|
| `ctx.storage` | 插件私有持久化(按插件 id 命名空间) |
|
||||||
|
|
||||||
|
> `metadata` 的**内置字段**(content、status、model…)是强类型;插件写入的**自定义字段**读出为 `unknown`,按需 `as` 断言(参考 `sticky-note` 的 `pluginColor`)。
|
||||||
|
|
||||||
|
### 画布指令集(ctx.applyOps)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
ctx.applyOps([
|
||||||
|
{ type: "add_node", id?, nodeType, title?, x?, y?, width?, height?, metadata? },
|
||||||
|
{ type: "update_node", id, patch?, metadata? },
|
||||||
|
{ type: "delete_node", id? | ids? },
|
||||||
|
{ type: "connect_nodes", fromNodeId, toNodeId },
|
||||||
|
{ type: "delete_connections", id? | ids? | all? },
|
||||||
|
{ type: "select_nodes", ids },
|
||||||
|
{ type: "set_viewport", viewport },
|
||||||
|
{ type: "run_generation", nodeId, mode?, prompt? },
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 重依赖 / 资源
|
||||||
|
|
||||||
|
- **重依赖**(three.js、marked 等):不要打进 bundle,运行时 `await import("https://esm.sh/...")` 动态加载(esbuild 自动 external);在 `src/env.d.ts` 声明该模块以通过 tsc。参考 `panorama/`、`markdown/`。
|
||||||
|
- **CSS**:写独立 `.css`,`import css from "./styles.css"` 拿到字符串(esbuild `text` loader),放到 `css` 字段自动注入/清理;`src/env.d.ts` 声明 `*.css`。参考 `markdown/`。
|
||||||
|
- **HTML**:HTML 节点把 HTML 字符串塞进 sandbox iframe 的 `srcDoc`,自带 `<style>`,不需要插件级 CSS。参考 `html/`。
|
||||||
|
|
||||||
|
## 兼容说明
|
||||||
|
|
||||||
|
加载器仍接受**默认导出为工厂函数** `(runtime) => CanvasPlugin`(用 `runtime.React`)或**普通对象**;老的 JS 插件无需改动即可运行。SDK 的 automatic JSX 让新插件走对象形式,更简洁。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
|
||||||
|
- 插件代码会在画布页面内**直接执行**,可访问浏览器本地数据(含 AI API Key)。发布前请自审,用户也只应安装可信来源。
|
||||||
|
- 交互控件记得 `onMouseDown={(e) => e.stopPropagation()}`(避免触发节点拖拽),滚动区域加 `onWheel={(e) => e.stopPropagation()}` 与容器 `data-canvas-no-zoom`(避免被画布缩放拦截)。
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# HTML 节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:用沙箱 iframe 渲染 HTML。源码里的 `{{input}}` 会被替换为上游文本节点内容。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/html.js,并同步到 web/public/plugins/html.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/html.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
Generated
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-html",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-html",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-html",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas HTML 节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
||||||
|
// 交互:预览态 iframe 默认 pointer-events:none —— 鼠标事件穿透到宿主节点体,
|
||||||
|
// 因此点节点任意位置都能拖动,无需在节点上加任何标题栏/按钮。
|
||||||
|
// 「编辑/预览」与「交互」开关都放在节点外的悬浮工具条(toolbar 扩展点),状态存 metadata。
|
||||||
|
import { definePlugin, useMemo, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
|
||||||
|
const EDITOR_FONT = 12;
|
||||||
|
const EDITOR_LINE = 20;
|
||||||
|
|
||||||
|
function HtmlEditor({ ctx, value }: { ctx: CanvasNodeContentProps["ctx"]; value: string }) {
|
||||||
|
const gutterRef = useRef<HTMLDivElement>(null);
|
||||||
|
// 行数:按换行统计,至少 1 行;value 变化时重算
|
||||||
|
const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
|
||||||
|
const [scrollTop, setScrollTop] = useState(0);
|
||||||
|
|
||||||
|
const codeStyle = { fontFamily: "monospace", fontSize: EDITOR_FONT, lineHeight: `${EDITOR_LINE}px`, boxSizing: "border-box" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-canvas-no-zoom style={{ height: "100%", width: "100%", display: "flex", overflow: "hidden", borderRadius: 16, background: ctx.theme.node.fill }} onMouseDown={(e) => e.stopPropagation()}>
|
||||||
|
{/* 行号槽:跟随文本域滚动,不可单独滚动 */}
|
||||||
|
<div
|
||||||
|
ref={gutterRef}
|
||||||
|
aria-hidden
|
||||||
|
style={{
|
||||||
|
...codeStyle,
|
||||||
|
flex: "0 0 auto",
|
||||||
|
padding: "16px 8px 16px 12px",
|
||||||
|
textAlign: "right",
|
||||||
|
color: ctx.theme.node.placeholder,
|
||||||
|
background: `${ctx.theme.toolbar.panel}66`,
|
||||||
|
borderRight: `1px solid ${ctx.theme.node.stroke}`,
|
||||||
|
overflow: "hidden",
|
||||||
|
userSelect: "none",
|
||||||
|
whiteSpace: "pre",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 用负 margin 让整列跟随 scrollTop 平移,和 textarea 同步 */}
|
||||||
|
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
||||||
|
{Array.from({ length: lineCount }, (_, i) => (
|
||||||
|
<div key={i}>{i + 1}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
placeholder="<div>Hello, {{input}}</div>"
|
||||||
|
spellCheck={false}
|
||||||
|
wrap="off"
|
||||||
|
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||||
|
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
style={{ ...codeStyle, flex: "1 1 auto", minWidth: 0, height: "100%", resize: "none", background: "transparent", padding: "16px 16px 16px 12px", outline: "none", border: "none", color: ctx.theme.node.text, whiteSpace: "pre", overflow: "auto" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const value = ctx.node.metadata?.content || "";
|
||||||
|
const editing = Boolean(ctx.node.metadata?.editing);
|
||||||
|
const upstreamText = useMemo(
|
||||||
|
() =>
|
||||||
|
ctx
|
||||||
|
.getUpstream()
|
||||||
|
.map((node) => node.metadata?.content)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
[ctx],
|
||||||
|
);
|
||||||
|
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return <HtmlEditor ctx={ctx} value={value} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
|
||||||
|
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
||||||
|
<span style={{ fontSize: 13 }}>选中节点,点上方工具条的 ✎ 编辑 HTML</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览态:iframe 的鼠标交互由宿主「交互 ⇄ 移动」开关统一控制(见 interactionToggle),
|
||||||
|
// 这里无需再手动做 pointer-events 穿透。data-canvas-no-zoom 保证交互时滚动作用于页面而非缩放画布。
|
||||||
|
return (
|
||||||
|
<div data-canvas-no-zoom style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||||
|
<iframe
|
||||||
|
title="html-preview"
|
||||||
|
sandbox="allow-scripts allow-forms"
|
||||||
|
srcDoc={html}
|
||||||
|
style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff", display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "html",
|
||||||
|
name: "HTML 节点",
|
||||||
|
version: "1.2.0",
|
||||||
|
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "html:render",
|
||||||
|
title: "HTML",
|
||||||
|
icon: "🌐",
|
||||||
|
description: "沙箱渲染 HTML",
|
||||||
|
defaultSize: { width: 420, height: 320 },
|
||||||
|
defaultMetadata: { content: "" },
|
||||||
|
minimapColor: "#ec4899",
|
||||||
|
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
|
||||||
|
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互(编辑器始终可操作)并隐藏该开关
|
||||||
|
interactionToggle: true,
|
||||||
|
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||||
|
Content: HtmlContent,
|
||||||
|
// 仅保留「编辑/预览」开关;交互/移动 由宿主自动注入
|
||||||
|
toolbar: (ctx) => {
|
||||||
|
const editing = Boolean(ctx.node.metadata?.editing);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "html-toggle-edit",
|
||||||
|
title: editing ? "预览渲染结果" : "编辑 HTML 源码",
|
||||||
|
label: editing ? "预览" : "编辑",
|
||||||
|
icon: editing ? "👁" : "✎",
|
||||||
|
active: editing,
|
||||||
|
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Markdown 节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:在画布里编辑与渲染 Markdown。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/markdown.js,并同步到 web/public/plugins/markdown.js
|
||||||
|
npm run dev # watch,改动自动构建
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/markdown.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
`npm run dev` 起 watch,在 `web/.env.local` 加 `VITE_DEV_PLUGINS=/plugins/markdown.js`,起画布后改 `src/index.jsx` 刷新页面即生效,无需反复安装。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
|
|
||||||
|
> 本插件演示了独立 CSS 文件用法:`src/styles.css` 经 esbuild `text` loader 打进 bundle,通过插件 `css` 字段自动注入/清理。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-markdown",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-markdown",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-markdown",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas Markdown 节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+10
@@ -0,0 +1,10 @@
|
|||||||
|
// esbuild 以 text loader 把 .css 打成字符串
|
||||||
|
declare module "*.css" {
|
||||||
|
const css: string;
|
||||||
|
export default css;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重依赖从 CDN 动态加载,不打进 bundle;此处声明以便 TS 提示
|
||||||
|
declare module "https://esm.sh/marked@14" {
|
||||||
|
export const marked: { parse: (src: string) => string };
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
|
||||||
|
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
|
||||||
|
// 切「交互」态可滚动/选择;编辑态强制可交互。
|
||||||
|
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
|
||||||
|
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
|
||||||
|
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
|
||||||
|
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
import css from "./styles.css";
|
||||||
|
|
||||||
|
type Marked = { parse: (src: string) => string };
|
||||||
|
|
||||||
|
let marked: Marked | undefined; // 加载完成后的 marked 实例,模块级缓存,后续同步渲染
|
||||||
|
let markedPromise: Promise<Marked> | undefined;
|
||||||
|
function loadMarked(): Promise<Marked> {
|
||||||
|
if (marked) return Promise.resolve(marked);
|
||||||
|
if (!markedPromise) markedPromise = import("https://esm.sh/marked@14").then((mod: { marked: Marked }) => (marked = mod.marked));
|
||||||
|
return markedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLACEHOLDER = "*选中节点,点上方工具条的 ✎ 编辑 Markdown*";
|
||||||
|
|
||||||
|
// 解析结果按源码缓存(模块级):同一段 Markdown 只解析一次,重复渲染/重挂载都命中缓存,
|
||||||
|
// 返回的是同一个字符串引用,配合下方「值不变不写 DOM」彻底避免图片被重新请求。
|
||||||
|
const htmlCache = new Map<string, string>();
|
||||||
|
function renderMarkdown(source: string): string {
|
||||||
|
if (!marked) return "";
|
||||||
|
const key = source || PLACEHOLDER;
|
||||||
|
let out = htmlCache.get(key);
|
||||||
|
if (out === undefined) {
|
||||||
|
out = marked.parse(key);
|
||||||
|
htmlCache.set(key, out);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预览态:用 ref 手动写 innerHTML,且仅在 html 真正变化时写入。
|
||||||
|
// 这样宿主的任何重渲染(点击、移动视角、选中态变化等)都不会触碰已渲染的 DOM,图片不会重新加载。
|
||||||
|
function MarkdownPreview({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [, force] = useState(0);
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const lastHtml = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// marked 未就绪时按需加载,加载完触发一次重渲染填充内容
|
||||||
|
useEffect(() => {
|
||||||
|
if (marked) return;
|
||||||
|
let alive = true;
|
||||||
|
loadMarked().then(() => alive && force((n) => n + 1));
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const source = (ctx.node.metadata?.content as string | undefined) || "";
|
||||||
|
const html = renderMarkdown(source);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || lastHtml.current === html) return; // 内容没变:不动 DOM,已加载的图片保持原样
|
||||||
|
el.innerHTML = html;
|
||||||
|
lastHtml.current = html;
|
||||||
|
}, [html]);
|
||||||
|
|
||||||
|
return <div ref={ref} className="cnv-md" data-canvas-no-zoom onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", color: ctx.theme.node.text }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownEditor({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const value = (ctx.node.metadata?.content as string | undefined) || "";
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={value}
|
||||||
|
placeholder="# 输入 Markdown"
|
||||||
|
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, boxSizing: "border-box", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
return ctx.node.metadata?.editing ? <MarkdownEditor ctx={ctx} /> : <MarkdownPreview ctx={ctx} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "markdown",
|
||||||
|
name: "Markdown 节点",
|
||||||
|
version: "1.1.0",
|
||||||
|
description: "在画布中编辑与渲染 Markdown",
|
||||||
|
css,
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "markdown:doc",
|
||||||
|
title: "Markdown",
|
||||||
|
icon: "📝",
|
||||||
|
description: "编辑与渲染 Markdown",
|
||||||
|
defaultSize: { width: 360, height: 300 },
|
||||||
|
defaultMetadata: { content: "" },
|
||||||
|
minimapColor: "#6366f1",
|
||||||
|
hidePanel: true, // 纯展示/编辑节点:不弹出下方生图面板
|
||||||
|
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互并隐藏该开关
|
||||||
|
interactionToggle: true,
|
||||||
|
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||||
|
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||||
|
Content: MarkdownContent,
|
||||||
|
// 仅保留「编辑/预览」开关(状态存 metadata.editing);交互/移动 由宿主自动注入
|
||||||
|
toolbar: (ctx) => {
|
||||||
|
const editing = Boolean(ctx.node.metadata?.editing);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "md-toggle-edit",
|
||||||
|
title: editing ? "预览渲染结果" : "编辑 Markdown 源码",
|
||||||
|
label: editing ? "预览" : "编辑",
|
||||||
|
icon: editing ? "👁" : "✎",
|
||||||
|
active: editing,
|
||||||
|
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
.cnv-md {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.cnv-md h1,
|
||||||
|
.cnv-md h2,
|
||||||
|
.cnv-md h3 {
|
||||||
|
margin: 0.6em 0 0.3em;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.cnv-md h1 {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.cnv-md h2 {
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
.cnv-md p {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.cnv-md a {
|
||||||
|
color: #6366f1;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.cnv-md code {
|
||||||
|
padding: 0.1em 0.35em;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(120, 120, 120, 0.16);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.cnv-md pre {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(120, 120, 120, 0.14);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.cnv-md pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.cnv-md ul,
|
||||||
|
.cnv-md ol {
|
||||||
|
padding-left: 1.4em;
|
||||||
|
margin: 0.5em 0;
|
||||||
|
}
|
||||||
|
.cnv-md blockquote {
|
||||||
|
margin: 0.5em 0;
|
||||||
|
padding-left: 0.8em;
|
||||||
|
border-left: 3px solid rgba(120, 120, 120, 0.4);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.cnv-md img {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 3D 全景节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:查看 360° 等距柱状(equirectangular)全景图,可拖拽旋转。可从上游图片节点自动取图。three.js 运行时从 CDN 按需加载,不打进插件体积。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/panorama.js,并同步到 web/public/plugins/panorama.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/panorama.js`(或托管后的公网 URL)。连接一个全景图片节点到本节点即可查看。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-panorama",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-panorama",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-panorama",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 3D 全景节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
// three 从 CDN 动态加载,不打进 bundle;声明为宽松类型以便通过 tsc
|
||||||
|
declare module "https://esm.sh/three@0.180.0";
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
// 3D 全景节点:等距柱状(equirectangular)360° 全景查看器。
|
||||||
|
// 三种取图方式:① 从上游图片节点自动取图 ② 本地上传 ③ AI 生成(复用宿主内置生成面板)。
|
||||||
|
// three.js 从 CDN 按需加载,不打进 bundle。AI 生成通过 useBuiltinPanel 声明,结果写回本节点。
|
||||||
|
import { definePlugin, useCallback, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps, CanvasNodeContext } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
|
||||||
|
let threePromise: Promise<any> | undefined;
|
||||||
|
function loadThree(): Promise<any> {
|
||||||
|
if (!threePromise) threePromise = import("https://esm.sh/three@0.180.0");
|
||||||
|
return threePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 生成的固定前置提示词:约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||||
|
// 用户只需描述场景内容,这段会自动拼在前面,保证 2:1、无缝、无畸变文字等。
|
||||||
|
const PANORAMA_SYSTEM_PROMPT =
|
||||||
|
"A seamless 360-degree equirectangular panorama, 2:1 aspect ratio, full spherical VR photo, " +
|
||||||
|
"horizontally wrapping seamlessly at the left and right edges, no visible seam, no distortion artifacts, " +
|
||||||
|
"even horizon, no text, no watermark. Scene: ";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 全景查看器:球体内壁贴等距柱状纹理,指针拖动转视角、滚轮缩放 fov。
|
||||||
|
// 自适应容器尺寸(ResizeObserver);超过 GPU 上限的大图先降采样,避免加载失败。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function PanoramaViewer({ src, ctx }: { src: string; ctx: CanvasNodeContext }) {
|
||||||
|
const mountRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mount = mountRef.current;
|
||||||
|
if (!mount || !src) return;
|
||||||
|
let disposed = false;
|
||||||
|
let frame = 0;
|
||||||
|
let renderer: any = null;
|
||||||
|
let geometry: any = null;
|
||||||
|
let material: any = null;
|
||||||
|
let texture: any = null;
|
||||||
|
let resizeObserver: ResizeObserver | null = null;
|
||||||
|
let cleanupEvents = () => {};
|
||||||
|
|
||||||
|
setStatus("loading");
|
||||||
|
|
||||||
|
loadThree()
|
||||||
|
.then((THREE: any) => {
|
||||||
|
if (disposed || !mountRef.current) return;
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
|
||||||
|
const target = new THREE.Vector3();
|
||||||
|
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
renderer.setClearColor(0x000000, 1);
|
||||||
|
const canvas: HTMLCanvasElement = renderer.domElement;
|
||||||
|
canvas.style.width = "100%";
|
||||||
|
canvas.style.height = "100%";
|
||||||
|
canvas.style.display = "block";
|
||||||
|
canvas.style.cursor = "grab";
|
||||||
|
mount.appendChild(canvas);
|
||||||
|
const maxTextureSide = Math.min(renderer.capabilities.maxTextureSize || 4096, 4096);
|
||||||
|
|
||||||
|
geometry = new THREE.SphereGeometry(500, 96, 64);
|
||||||
|
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
|
||||||
|
material = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||||
|
scene.add(new THREE.Mesh(geometry, material));
|
||||||
|
|
||||||
|
// 视角状态
|
||||||
|
let lon = 0;
|
||||||
|
let lat = 0;
|
||||||
|
let fov = 70;
|
||||||
|
let dragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
let startLon = 0;
|
||||||
|
let startLat = 0;
|
||||||
|
|
||||||
|
const onDown = (event: PointerEvent) => {
|
||||||
|
dragging = true;
|
||||||
|
startX = event.clientX;
|
||||||
|
startY = event.clientY;
|
||||||
|
startLon = lon;
|
||||||
|
startLat = lat;
|
||||||
|
canvas.style.cursor = "grabbing";
|
||||||
|
canvas.setPointerCapture?.(event.pointerId);
|
||||||
|
};
|
||||||
|
const onMove = (event: PointerEvent) => {
|
||||||
|
if (!dragging) return;
|
||||||
|
lon = startLon - (event.clientX - startX) * 0.12;
|
||||||
|
lat = Math.max(-84, Math.min(84, startLat + (event.clientY - startY) * 0.12));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
dragging = false;
|
||||||
|
canvas.style.cursor = "grab";
|
||||||
|
};
|
||||||
|
const onWheel = (event: WheelEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
fov = Math.max(38, Math.min(86, fov + event.deltaY * 0.035));
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointerdown", onDown);
|
||||||
|
canvas.addEventListener("pointermove", onMove);
|
||||||
|
canvas.addEventListener("pointerup", onUp);
|
||||||
|
canvas.addEventListener("pointercancel", onUp);
|
||||||
|
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||||
|
cleanupEvents = () => {
|
||||||
|
canvas.removeEventListener("pointerdown", onDown);
|
||||||
|
canvas.removeEventListener("pointermove", onMove);
|
||||||
|
canvas.removeEventListener("pointerup", onUp);
|
||||||
|
canvas.removeEventListener("pointercancel", onUp);
|
||||||
|
canvas.removeEventListener("wheel", onWheel);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
if (!renderer || !mountRef.current) return;
|
||||||
|
const width = Math.max(1, mountRef.current.clientWidth);
|
||||||
|
const height = Math.max(1, mountRef.current.clientHeight);
|
||||||
|
renderer.setSize(width, height, false);
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
resizeObserver = new ResizeObserver(resize);
|
||||||
|
resizeObserver.observe(mount);
|
||||||
|
resize();
|
||||||
|
|
||||||
|
const render = () => {
|
||||||
|
if (disposed || !renderer) return;
|
||||||
|
if (!dragging) lon += 0.02; // 缓慢自转
|
||||||
|
camera.fov = fov;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
const phi = THREE.MathUtils.degToRad(90 - lat);
|
||||||
|
const theta = THREE.MathUtils.degToRad(lon);
|
||||||
|
target.set(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
||||||
|
camera.lookAt(target);
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
frame = requestAnimationFrame(render);
|
||||||
|
};
|
||||||
|
render();
|
||||||
|
|
||||||
|
// 载入纹理:大图降采样到 GPU 上限,避免超限导致黑屏/失败
|
||||||
|
const image = new Image();
|
||||||
|
image.crossOrigin = "anonymous";
|
||||||
|
image.onload = () => {
|
||||||
|
if (disposed || !material) return;
|
||||||
|
try {
|
||||||
|
const w = image.naturalWidth || image.width;
|
||||||
|
const h = image.naturalHeight || image.height;
|
||||||
|
const scale = Math.min(1, maxTextureSide / w, maxTextureSide / h);
|
||||||
|
let source: HTMLImageElement | HTMLCanvasElement = image;
|
||||||
|
if (scale < 1) {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = Math.max(1, Math.round(w * scale));
|
||||||
|
c.height = Math.max(1, Math.round(h * scale));
|
||||||
|
c.getContext("2d")?.drawImage(image, 0, 0, c.width, c.height);
|
||||||
|
source = c;
|
||||||
|
}
|
||||||
|
texture = source instanceof HTMLCanvasElement ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
|
||||||
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
|
texture.minFilter = THREE.LinearFilter;
|
||||||
|
texture.needsUpdate = true;
|
||||||
|
material.map = texture;
|
||||||
|
material.needsUpdate = true;
|
||||||
|
setStatus("ready");
|
||||||
|
} catch {
|
||||||
|
setStatus("error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
image.onerror = () => !disposed && setStatus("error");
|
||||||
|
image.src = src;
|
||||||
|
})
|
||||||
|
.catch(() => !disposed && setStatus("error"));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
cleanupEvents();
|
||||||
|
resizeObserver?.disconnect();
|
||||||
|
if (frame) cancelAnimationFrame(frame);
|
||||||
|
texture?.dispose?.();
|
||||||
|
material?.dispose?.();
|
||||||
|
geometry?.dispose?.();
|
||||||
|
if (renderer) {
|
||||||
|
renderer.domElement?.remove?.();
|
||||||
|
renderer.dispose?.();
|
||||||
|
renderer.forceContextLoss?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }}>
|
||||||
|
<div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} />
|
||||||
|
{status !== "ready" ? (
|
||||||
|
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", color: ctx.theme.node.placeholder, fontSize: 13, background: status === "error" ? "rgba(0,0,0,0.6)" : `linear-gradient(135deg, ${ctx.theme.node.fill}, #000)` }}>
|
||||||
|
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。
|
||||||
|
// 整块不可点,避免误触;拖动节点照常。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) {
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 本地上传:读为 dataURL 写入自身 metadata.content
|
||||||
|
const onPick = useCallback(
|
||||||
|
(file: File | undefined) => {
|
||||||
|
if (!file) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => ctx.updateMetadata({ content: String(reader.result || "") });
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
},
|
||||||
|
[ctx],
|
||||||
|
);
|
||||||
|
|
||||||
|
const t = ctx.theme;
|
||||||
|
const baseBtn = {
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 6,
|
||||||
|
cursor: "pointer",
|
||||||
|
borderRadius: 999,
|
||||||
|
padding: "9px 18px",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
border: `1px solid ${t.node.stroke}`,
|
||||||
|
background: t.node.fill,
|
||||||
|
color: t.node.text,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 20, borderRadius: 16, boxSizing: "border-box", background: t.node.fill }}>
|
||||||
|
<span style={{ fontSize: 34 }}>🌐</span>
|
||||||
|
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
|
||||||
|
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => onPick(e.target.files?.[0])} />
|
||||||
|
<button type="button" style={baseBtn} onClick={() => fileRef.current?.click()}>
|
||||||
|
🖼 上传全景图
|
||||||
|
</button>
|
||||||
|
<button type="button" style={{ ...baseBtn, border: "none", background: t.toolbar.activeBg, color: t.toolbar.activeText }} onClick={() => ctx.openPanel()}>
|
||||||
|
✨ AI 生成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span style={{ fontSize: 12, color: t.node.placeholder, textAlign: "center" }}>支持 2:1 等距柱状全景图</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const upstreamImage = ctx
|
||||||
|
.getUpstream()
|
||||||
|
.map((node) => node.metadata?.content)
|
||||||
|
.find((content): content is string => typeof content === "string" && Boolean(content));
|
||||||
|
const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
|
||||||
|
|
||||||
|
if (!source) return <PanoramaEmpty ctx={ctx} />;
|
||||||
|
return <PanoramaViewer src={source} ctx={ctx} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "panorama",
|
||||||
|
name: "3D 全景节点",
|
||||||
|
version: "1.1.0",
|
||||||
|
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "panorama:viewer",
|
||||||
|
title: "3D 全景",
|
||||||
|
icon: "🌐",
|
||||||
|
description: "360° 全景查看器(上传 / AI 生成)",
|
||||||
|
defaultSize: { width: 480, height: 300 },
|
||||||
|
defaultMetadata: {},
|
||||||
|
minimapColor: "#0ea5e9",
|
||||||
|
// 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
|
||||||
|
interactionToggle: true,
|
||||||
|
// 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
|
||||||
|
// 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||||
|
useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
|
||||||
|
// 作为上游被消费时,输出自身全景图,供下游节点引用
|
||||||
|
resource: (node) => {
|
||||||
|
const content = node.metadata?.content;
|
||||||
|
return typeof content === "string" && content ? { kind: "image", url: content } : null;
|
||||||
|
},
|
||||||
|
Content: PanoramaContent,
|
||||||
|
// 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
|
||||||
|
toolbar: (ctx) => {
|
||||||
|
const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "panorama-generate",
|
||||||
|
title: "用 AI 生成全景图(打开下方面板)",
|
||||||
|
label: "AI 生成",
|
||||||
|
icon: "✨",
|
||||||
|
onClick: () => ctx.openPanel(),
|
||||||
|
},
|
||||||
|
...(hasOwnContent
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "panorama-reset",
|
||||||
|
title: "清空当前全景图,重新上传",
|
||||||
|
label: "换图",
|
||||||
|
icon: "🔄",
|
||||||
|
onClick: () => ctx.updateMetadata({ content: "" }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# 官方插件注册表(集中构建 + 远程发布)
|
||||||
|
|
||||||
|
本目录**只放构建脚本**,不放构建产物。官方插件由 CI 构建后发布到孤儿分支 `plugins-dist`,画布经 jsDelivr 从该分支远程拉取并一键安装。第三方插件不进本流程,由用户自行填 JS URL 安装。
|
||||||
|
|
||||||
|
```
|
||||||
|
registry/
|
||||||
|
package.json # 构建依赖(esbuild + SDK)
|
||||||
|
build.mjs # 一次进程构建所有官方插件 → dist/(gitignore)+ 生成清单
|
||||||
|
dist/ # 构建产物(gitignore,不提交;CI 发布到 plugins-dist 分支)
|
||||||
|
```
|
||||||
|
|
||||||
|
**产物不进 git**:`dist/` 与 `node_modules/` 均被 `.gitignore` 覆盖。`main` 分支只有源码与脚本。
|
||||||
|
|
||||||
|
## 发布流程(CI 自动)
|
||||||
|
|
||||||
|
`.github/workflows/publish-plugins.yml` 在**打版本 tag(`v*`)**或手动触发(`workflow_dispatch`)时,与 GitHub Pages 发布一起跑:
|
||||||
|
|
||||||
|
1. `npm install && npm run build` → 在 `dist/` 产出各 `<id>.js` 与 `official-plugins.json`;
|
||||||
|
2. 把 `dist/` 强推到孤儿分支 **`plugins-dist`**(仅含产物,force-push 覆盖)。
|
||||||
|
|
||||||
|
前端默认从下面地址读取(可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖):
|
||||||
|
|
||||||
|
```
|
||||||
|
https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json
|
||||||
|
```
|
||||||
|
|
||||||
|
清单里每条的 `entry`(相对文件名)由前端解析成与清单同目录的绝对 URL,再走既有 URL 安装流程。jsDelivr 对分支有缓存(约数小时),需要立即生效可对该分支目录做 purge。
|
||||||
|
|
||||||
|
## 新增 / 更新官方插件
|
||||||
|
|
||||||
|
- 改完某官方插件源码后,在 `build.mjs` 的 `OFFICIAL` 里保持登记(新增插件在此加一条),提交到 `main`;
|
||||||
|
- 下次打版本 tag(或手动 `workflow_dispatch`)时,CI 自动重新构建并发布到 `plugins-dist`。
|
||||||
|
|
||||||
|
## 本地自测官方面板
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/canvas/registry && npm install && npm run build # 产出 dist/
|
||||||
|
# 用任意静态服务器伺服 dist/,把 VITE_PLUGIN_REGISTRY_URL 指向其 official-plugins.json
|
||||||
|
```
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// 官方插件集中构建:一次进程构建所有官方插件 + 生成清单,产物进 dist/(已 gitignore)。
|
||||||
|
// 官方插件目录本身不各自安装依赖:统一从本目录 node_modules 解析 SDK(nodePaths)。
|
||||||
|
// CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。
|
||||||
|
// 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const outDir = join(root, "dist");
|
||||||
|
const nodeModules = join(root, "node_modules");
|
||||||
|
|
||||||
|
// 官方插件登记表:新增官方插件在此登记即可。
|
||||||
|
// 版本不在这里写死 —— 从各插件的 package.json 读取(单一真源),
|
||||||
|
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
|
||||||
|
const OFFICIAL = [
|
||||||
|
{ id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
|
||||||
|
{ id: "svg", dir: "svg", name: "SVG 节点", description: "透明背景渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||||
|
{ id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
|
||||||
|
{ id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
|
||||||
|
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", description: "可自选颜色、双击编辑、拖动即可移动的便利贴", icon: "📌" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 读取插件 package.json 的 version 作为清单版本的唯一来源
|
||||||
|
async function readPluginVersion(dir) {
|
||||||
|
const pkg = JSON.parse(await readFile(join(root, "..", dir, "package.json"), "utf8"));
|
||||||
|
if (!pkg.version) throw new Error(`插件 ${dir} 的 package.json 缺少 version`);
|
||||||
|
return pkg.version;
|
||||||
|
}
|
||||||
|
|
||||||
|
await rm(outDir, { recursive: true, force: true });
|
||||||
|
await mkdir(outDir, { recursive: true });
|
||||||
|
|
||||||
|
for (const plugin of OFFICIAL) {
|
||||||
|
await build({
|
||||||
|
entryPoints: [join(root, "..", plugin.dir, "src", "index.tsx")],
|
||||||
|
outfile: join(outDir, `${plugin.id}.js`),
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2020",
|
||||||
|
// automatic JSX → SDK 的 jsx-runtime(内部用宿主 React),react external
|
||||||
|
jsx: "automatic",
|
||||||
|
jsxImportSource: "@infinite-canvas/plugin-sdk",
|
||||||
|
loader: { ".ts": "ts", ".tsx": "tsx", ".css": "text" },
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
minify: true,
|
||||||
|
nodePaths: [nodeModules],
|
||||||
|
});
|
||||||
|
console.log(`built ${plugin.id}.js`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const manifest = {
|
||||||
|
// 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL
|
||||||
|
version: 1,
|
||||||
|
plugins: await Promise.all(
|
||||||
|
OFFICIAL.map(async (plugin) => ({
|
||||||
|
id: plugin.id,
|
||||||
|
name: plugin.name,
|
||||||
|
version: await readPluginVersion(plugin.dir),
|
||||||
|
description: plugin.description,
|
||||||
|
icon: plugin.icon,
|
||||||
|
entry: `${plugin.id}.js`,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
|
||||||
|
console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`);
|
||||||
+517
@@ -0,0 +1,517 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-official-plugins",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-official-plugins",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-official-plugins",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 官方插件的集中构建(CI 发布到 plugins-dist 分支)",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"esbuild": "^0.25.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# @infinite-canvas/plugin-sdk
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件的 **TypeScript SDK**。插件作者只写节点 UI 与逻辑,类型、JSX、运行时桥接、构建全部由 SDK 提供;产物仍是宿主加载器现有契约的 ESM(React external,宿主单例)。
|
||||||
|
|
||||||
|
## 提供什么
|
||||||
|
|
||||||
|
| 能力 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| **完整类型** | `CanvasPlugin` / `CanvasNodeDefinition` / `CanvasNodeContext` / `CanvasAgentOp` / `CanvasTheme` / `CanvasNodeData` … 全部有提示 |
|
||||||
|
| `definePlugin(...)` | 给插件对象(或工厂)补全类型;对象形式无需再 `const { React } = runtime` |
|
||||||
|
| automatic JSX | `jsxImportSource` 指向本包,TSX 自动转发到宿主 React,**不打包第二份 React** |
|
||||||
|
| 类型化 hooks | `import { useState, useEffect, useMemo, useRef, ... }`,运行时转发宿主 React |
|
||||||
|
| `buildPlugin(...)` | 统一 esbuild 构建,插件 `build.mjs` 只需一行 |
|
||||||
|
|
||||||
|
## 最小插件
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// src/index.tsx
|
||||||
|
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
function Content({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [n, setN] = useState(0);
|
||||||
|
return (
|
||||||
|
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||||
|
{ctx.node.title}: {n}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "my-plugin",
|
||||||
|
name: "我的插件",
|
||||||
|
version: "1.0.0",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "my-plugin:node",
|
||||||
|
title: "示例",
|
||||||
|
icon: "✨",
|
||||||
|
defaultSize: { width: 240, height: 160 },
|
||||||
|
Content,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
// build.mjs
|
||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm run build` 产出 `dist/<目录名>.js` 并同步到 `web/public/plugins/`。
|
||||||
|
|
||||||
|
## 依赖接入
|
||||||
|
|
||||||
|
插件 `package.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "module",
|
||||||
|
"scripts": { "build": "node build.mjs", "dev": "node build.mjs --watch", "typecheck": "tsc --noEmit" },
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
插件 `tsconfig.json` 关键项:`"jsx": "react-jsx"`、`"jsxImportSource": "@infinite-canvas/plugin-sdk"`、`"moduleResolution": "bundler"`。
|
||||||
|
|
||||||
|
## 设计约束
|
||||||
|
|
||||||
|
- **React 单例**:JSX 与 hooks 惰性读取 `globalThis.InfiniteCanvasRuntime.React`(宿主在加载插件前注入),react 全程 external,绝不打包第二份。
|
||||||
|
- **重依赖**:three、marked 等在源码里 `await import("https://esm.sh/...")` 动态加载,esbuild 自动 external,不进 bundle。
|
||||||
|
- **类型真源**:`src/types.ts` 是宿主 `web/src/types/canvas-plugin.ts` 公开契约的镜像;宿主契约变更时同步此处。
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 共享构建助手:插件的 build.mjs 只需一行 `buildPlugin(import.meta.url)`。
|
||||||
|
// 统一 esbuild 配置(automatic JSX 指向本 SDK、react external、TS/TSX/CSS loader、
|
||||||
|
// 产物同步到 web/public/plugins),消除各插件重复的构建脚本。
|
||||||
|
|
||||||
|
import { build, context } from "esbuild";
|
||||||
|
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { basename, dirname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} metaUrl 插件 build.mjs 的 import.meta.url
|
||||||
|
* @param {{ name?: string, entry?: string, publicDir?: string, esbuild?: object, plugins?: import("esbuild").Plugin[] }} [overrides]
|
||||||
|
*/
|
||||||
|
export async function buildPlugin(metaUrl, overrides = {}) {
|
||||||
|
const root = dirname(fileURLToPath(metaUrl));
|
||||||
|
const name = overrides.name ?? basename(root); // 目录名即产物名,如 markdown → markdown.js
|
||||||
|
const distDir = join(root, "dist");
|
||||||
|
// 默认同步到仓库内 web/public/plugins(plugins/canvas/<name> → 上溯三层到仓库根)
|
||||||
|
const publicDir = overrides.publicDir ?? join(root, "..", "..", "..", "web", "public", "plugins");
|
||||||
|
const watch = process.argv.includes("--watch");
|
||||||
|
const entry = overrides.entry ?? join(root, "src", "index.tsx");
|
||||||
|
|
||||||
|
const syncToPublic = {
|
||||||
|
name: "sync-to-public",
|
||||||
|
setup(builder) {
|
||||||
|
builder.onEnd(async (result) => {
|
||||||
|
if (result.errors.length) return;
|
||||||
|
await mkdir(publicDir, { recursive: true });
|
||||||
|
await cp(join(distDir, `${name}.js`), join(publicDir, `${name}.js`));
|
||||||
|
// 维护本地插件清单 index.json,供画布启动时自动发现(该目录已 gitignore,仅本地开发用)
|
||||||
|
const indexPath = join(publicDir, "index.json");
|
||||||
|
let list = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(await readFile(indexPath, "utf8"));
|
||||||
|
if (Array.isArray(parsed)) list = parsed;
|
||||||
|
} catch {
|
||||||
|
// 无清单则新建
|
||||||
|
}
|
||||||
|
const entry = `/plugins/${name}.js`;
|
||||||
|
if (!list.includes(entry)) {
|
||||||
|
list.push(entry);
|
||||||
|
await writeFile(indexPath, JSON.stringify(list, null, 2) + "\n");
|
||||||
|
}
|
||||||
|
console.log(`[${name}] synced → web/public/plugins/${name}.js`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
entryPoints: [entry],
|
||||||
|
outfile: join(distDir, `${name}.js`),
|
||||||
|
bundle: true,
|
||||||
|
format: "esm",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2020",
|
||||||
|
// automatic JSX → 转发到本 SDK 的 jsx-runtime(内部用宿主 React),插件无需自带 React
|
||||||
|
jsx: "automatic",
|
||||||
|
jsxImportSource: "@infinite-canvas/plugin-sdk",
|
||||||
|
loader: { ".js": "jsx", ".jsx": "jsx", ".ts": "ts", ".tsx": "tsx", ".css": "text" },
|
||||||
|
// 宿主提供单例 React,插件不打包 react;https:// 依赖 esbuild 自动 external
|
||||||
|
external: ["react", "react-dom"],
|
||||||
|
minify: !watch,
|
||||||
|
plugins: [syncToPublic, ...(overrides.plugins ?? [])],
|
||||||
|
...overrides.esbuild,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
const ctx = await context(options);
|
||||||
|
await ctx.watch();
|
||||||
|
console.log(`[${name}] watching src/ ...`);
|
||||||
|
} else {
|
||||||
|
await build(options);
|
||||||
|
console.log(`[${name}] built → dist/${name}.js`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+535
@@ -0,0 +1,535 @@
|
|||||||
|
{
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openharmony"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.25.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||||
|
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.25.12",
|
||||||
|
"@esbuild/android-arm": "0.25.12",
|
||||||
|
"@esbuild/android-arm64": "0.25.12",
|
||||||
|
"@esbuild/android-x64": "0.25.12",
|
||||||
|
"@esbuild/darwin-arm64": "0.25.12",
|
||||||
|
"@esbuild/darwin-x64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/freebsd-x64": "0.25.12",
|
||||||
|
"@esbuild/linux-arm": "0.25.12",
|
||||||
|
"@esbuild/linux-arm64": "0.25.12",
|
||||||
|
"@esbuild/linux-ia32": "0.25.12",
|
||||||
|
"@esbuild/linux-loong64": "0.25.12",
|
||||||
|
"@esbuild/linux-mips64el": "0.25.12",
|
||||||
|
"@esbuild/linux-ppc64": "0.25.12",
|
||||||
|
"@esbuild/linux-riscv64": "0.25.12",
|
||||||
|
"@esbuild/linux-s390x": "0.25.12",
|
||||||
|
"@esbuild/linux-x64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/netbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-arm64": "0.25.12",
|
||||||
|
"@esbuild/openbsd-x64": "0.25.12",
|
||||||
|
"@esbuild/openharmony-arm64": "0.25.12",
|
||||||
|
"@esbuild/sunos-x64": "0.25.12",
|
||||||
|
"@esbuild/win32-arm64": "0.25.12",
|
||||||
|
"@esbuild/win32-ia32": "0.25.12",
|
||||||
|
"@esbuild/win32-x64": "0.25.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 画布节点插件 SDK:类型、automatic JSX 运行时、构建助手",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./jsx-runtime": "./src/jsx-runtime.ts",
|
||||||
|
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
|
||||||
|
"./build": "./build.mjs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { CanvasPlugin, CanvasPluginFactory, PluginRuntime } from "./types";
|
||||||
|
|
||||||
|
// 身份帮助函数:为插件对象(或工厂)补全类型,给作者完整补全与校验。
|
||||||
|
// 采用 automatic JSX 后,插件通常不再需要 runtime,直接写对象形式即可:
|
||||||
|
//
|
||||||
|
// export default definePlugin({ id, name, version, nodes: [...] })
|
||||||
|
//
|
||||||
|
// 仍支持工厂形式(需要 runtime.version/emit/on 时):
|
||||||
|
//
|
||||||
|
// export default definePlugin((runtime) => ({ ... }))
|
||||||
|
|
||||||
|
export function definePlugin(plugin: CanvasPlugin): CanvasPlugin;
|
||||||
|
export function definePlugin(factory: CanvasPluginFactory): CanvasPluginFactory;
|
||||||
|
export function definePlugin(input: CanvasPlugin | CanvasPluginFactory): CanvasPlugin | CanvasPluginFactory {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PluginRuntime };
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// @infinite-canvas/plugin-sdk 公开入口。
|
||||||
|
//
|
||||||
|
// 插件作者用 TS/TSX 编写,只关注节点 UI 与逻辑;类型、JSX、运行时桥接、构建
|
||||||
|
// 全部由本 SDK 提供,产物仍是宿主 loader 现有契约的 ESM(React external,宿主单例)。
|
||||||
|
|
||||||
|
export * from "./types";
|
||||||
|
export { definePlugin } from "./define-plugin";
|
||||||
|
export { getReact, getRuntime, useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useReducer, useContext, useId } from "./runtime";
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// automatic JSX 的 dev 变体(编译器在 dev 模式会引用 jsxDEV)。转发到同一套 createElement。
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { getReact } from "./runtime";
|
||||||
|
import { Fragment } from "./jsx-runtime";
|
||||||
|
|
||||||
|
export { Fragment };
|
||||||
|
export type { JSX } from "./jsx-runtime";
|
||||||
|
|
||||||
|
export function jsxDEV(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||||
|
const react = getReact();
|
||||||
|
const resolvedType = type === Fragment ? react.Fragment : type;
|
||||||
|
const config = key === undefined ? props : { ...(props ?? {}), key };
|
||||||
|
return react.createElement(resolvedType as never, config as never);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// automatic JSX 运行时:esbuild/tsc 的 `jsxImportSource` 指向本包时,
|
||||||
|
// TSX 会被编译成对本模块 jsx()/jsxs() 的调用。这里统一转发到宿主 React.createElement,
|
||||||
|
// 让插件写纯 TSX、无需手动 `const { React } = runtime`,同时 react 全程 external。
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import { getReact } from "./runtime";
|
||||||
|
|
||||||
|
// Fragment 哨兵:渲染时才解析为宿主 React.Fragment,避免模块顶层触碰运行时。
|
||||||
|
export const Fragment = Symbol.for("infinite-canvas.jsx.fragment") as unknown as React.ExoticComponent<{ children?: React.ReactNode }>;
|
||||||
|
|
||||||
|
function createElement(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||||
|
const react = getReact();
|
||||||
|
const resolvedType = type === Fragment ? react.Fragment : type;
|
||||||
|
// automatic 运行时已把 children 放进 props;key 单独传入以避免展开 key 警告。
|
||||||
|
const config = key === undefined ? props : { ...(props ?? {}), key };
|
||||||
|
return react.createElement(resolvedType as never, config as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function jsx(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||||
|
return createElement(type, props, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsxs 用于静态多子节点;转发逻辑与 jsx 一致(children 已在 props 内)。
|
||||||
|
export const jsxs = jsx;
|
||||||
|
|
||||||
|
// 让 `jsxImportSource` 指向本包的编译器能从这里取到 JSX 内建标签类型(复用 @types/react)。
|
||||||
|
export namespace JSX {
|
||||||
|
export type Element = React.JSX.Element;
|
||||||
|
export type ElementType = React.JSX.ElementType;
|
||||||
|
export type ElementClass = React.JSX.ElementClass;
|
||||||
|
export type ElementAttributesProperty = React.JSX.ElementAttributesProperty;
|
||||||
|
export type ElementChildrenAttribute = React.JSX.ElementChildrenAttribute;
|
||||||
|
export type LibraryManagedAttributes<C, P> = React.JSX.LibraryManagedAttributes<C, P>;
|
||||||
|
export type IntrinsicAttributes = React.JSX.IntrinsicAttributes;
|
||||||
|
export type IntrinsicClassAttributes<T> = React.JSX.IntrinsicClassAttributes<T>;
|
||||||
|
export type IntrinsicElements = React.JSX.IntrinsicElements;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// 运行时桥接:所有对宿主 React 的访问都惰性读取全局运行时,
|
||||||
|
// 保证插件与画布共用同一份 React(不打包第二份),且不在模块顶层触碰运行时
|
||||||
|
// (宿主 loader 会先 import 插件模块、再设置运行时)。
|
||||||
|
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
|
import type { PluginRuntime } from "./types";
|
||||||
|
|
||||||
|
type RuntimeGlobal = { InfiniteCanvasRuntime?: PluginRuntime };
|
||||||
|
|
||||||
|
/** 取宿主注入的插件运行时(含 React、事件总线、injectCSS 等)。 */
|
||||||
|
export function getRuntime(): PluginRuntime {
|
||||||
|
const runtime = (globalThis as unknown as RuntimeGlobal).InfiniteCanvasRuntime;
|
||||||
|
if (!runtime) {
|
||||||
|
throw new Error("[plugin-sdk] Infinite Canvas 运行时未就绪:请在画布宿主中加载本插件");
|
||||||
|
}
|
||||||
|
return runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取宿主的 React 实例。仅在渲染/hook 调用时(运行时已就绪)使用。 */
|
||||||
|
export function getReact(): typeof React {
|
||||||
|
return getRuntime().React as unknown as typeof React;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 类型完整的 hooks 转发:签名取自 @types/react,运行时转发到宿主 React ---
|
||||||
|
// 这样插件作者可以直接 `import { useState } from "@infinite-canvas/plugin-sdk"`,
|
||||||
|
// 无需再从 runtime 里解构 React。
|
||||||
|
|
||||||
|
export const useState: typeof React.useState = ((...args: unknown[]) => (getReact().useState as (...a: unknown[]) => unknown)(...args)) as typeof React.useState;
|
||||||
|
export const useEffect: typeof React.useEffect = ((...args: unknown[]) => (getReact().useEffect as (...a: unknown[]) => unknown)(...args)) as typeof React.useEffect;
|
||||||
|
export const useLayoutEffect: typeof React.useLayoutEffect = ((...args: unknown[]) => (getReact().useLayoutEffect as (...a: unknown[]) => unknown)(...args)) as typeof React.useLayoutEffect;
|
||||||
|
export const useMemo: typeof React.useMemo = ((...args: unknown[]) => (getReact().useMemo as (...a: unknown[]) => unknown)(...args)) as typeof React.useMemo;
|
||||||
|
export const useCallback: typeof React.useCallback = ((...args: unknown[]) => (getReact().useCallback as (...a: unknown[]) => unknown)(...args)) as typeof React.useCallback;
|
||||||
|
export const useRef: typeof React.useRef = ((...args: unknown[]) => (getReact().useRef as (...a: unknown[]) => unknown)(...args)) as typeof React.useRef;
|
||||||
|
export const useReducer: typeof React.useReducer = ((...args: unknown[]) => (getReact().useReducer as (...a: unknown[]) => unknown)(...args)) as typeof React.useReducer;
|
||||||
|
export const useContext: typeof React.useContext = ((...args: unknown[]) => (getReact().useContext as (...a: unknown[]) => unknown)(...args)) as typeof React.useContext;
|
||||||
|
export const useId: typeof React.useId = ((...args: unknown[]) => (getReact().useId as (...a: unknown[]) => unknown)(...args)) as typeof React.useId;
|
||||||
@@ -0,0 +1,331 @@
|
|||||||
|
// Infinite Canvas 插件公共契约类型。
|
||||||
|
//
|
||||||
|
// 这是插件作者面向的「公开接口」子集,自包含、不依赖宿主 `@/` 内部模块,
|
||||||
|
// 因此可以被独立构建的插件包直接 import,获得完整的 TS 提示。
|
||||||
|
//
|
||||||
|
// 真源:宿主 `web/src/types/canvas-plugin.ts` 及其引用的类型。本文件是它的公开镜像,
|
||||||
|
// 若宿主契约变更,请同步更新此处(两者结构保持一致即可,无需逐字节相同)。
|
||||||
|
|
||||||
|
import type { ComponentType, ReactNode } from "react";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 画布基础几何与节点数据
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Position = { x: number; y: number };
|
||||||
|
|
||||||
|
export type ViewportTransform = { x: number; y: number; k: number };
|
||||||
|
|
||||||
|
// 内置节点类型;插件节点建议用 "<pluginId>:<name>"。放开为字符串以便扩展。
|
||||||
|
export type CanvasBuiltinNodeType = "image" | "text" | "config" | "video" | "audio" | "group";
|
||||||
|
export type CanvasNodeTypeId = CanvasBuiltinNodeType | (string & {});
|
||||||
|
|
||||||
|
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||||
|
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||||
|
export type CanvasImageGenerationType = "generation" | "edit";
|
||||||
|
|
||||||
|
// 节点 metadata 是扁平可选字段袋;插件自定义字段可直接写入(内容惯例放 content)。
|
||||||
|
export type CanvasNodeMetadata = {
|
||||||
|
content?: string;
|
||||||
|
composerContent?: string;
|
||||||
|
prompt?: string;
|
||||||
|
status?: CanvasNodeStatus;
|
||||||
|
errorDetails?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
generationMode?: CanvasGenerationMode;
|
||||||
|
generationType?: CanvasImageGenerationType;
|
||||||
|
model?: string;
|
||||||
|
size?: string;
|
||||||
|
quality?: string;
|
||||||
|
count?: number;
|
||||||
|
seconds?: string;
|
||||||
|
vquality?: string;
|
||||||
|
generateAudio?: string;
|
||||||
|
watermark?: string;
|
||||||
|
audioVoice?: string;
|
||||||
|
audioFormat?: string;
|
||||||
|
audioSpeed?: string;
|
||||||
|
audioInstructions?: string;
|
||||||
|
references?: string[];
|
||||||
|
naturalWidth?: number;
|
||||||
|
naturalHeight?: number;
|
||||||
|
freeResize?: boolean;
|
||||||
|
isBatchRoot?: boolean;
|
||||||
|
batchRootId?: string;
|
||||||
|
batchChildIds?: string[];
|
||||||
|
batchUsesReferenceImages?: boolean;
|
||||||
|
primaryImageId?: string;
|
||||||
|
imageBatchExpanded?: boolean;
|
||||||
|
storageKey?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
bytes?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
groupId?: string;
|
||||||
|
// 插件可写入任意自定义字段
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeData = {
|
||||||
|
id: string;
|
||||||
|
type: CanvasNodeTypeId;
|
||||||
|
title: string;
|
||||||
|
position: Position;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
metadata?: CanvasNodeMetadata;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasConnection = {
|
||||||
|
id: string;
|
||||||
|
fromNodeId: string;
|
||||||
|
toNodeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 主题 token(用来让插件 UI 跟随画布明暗主题)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasTheme = {
|
||||||
|
canvas: {
|
||||||
|
background: string;
|
||||||
|
dot: string;
|
||||||
|
line: string;
|
||||||
|
selectionStroke: string;
|
||||||
|
selectionFill: string;
|
||||||
|
};
|
||||||
|
node: {
|
||||||
|
label: string;
|
||||||
|
fill: string;
|
||||||
|
panel: string;
|
||||||
|
stroke: string;
|
||||||
|
activeStroke: string;
|
||||||
|
placeholder: string;
|
||||||
|
text: string;
|
||||||
|
muted: string;
|
||||||
|
faint: string;
|
||||||
|
};
|
||||||
|
toolbar: {
|
||||||
|
panel: string;
|
||||||
|
border: string;
|
||||||
|
item: string;
|
||||||
|
itemHover: string;
|
||||||
|
activeBg: string;
|
||||||
|
activeText: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 画布指令集(ctx.applyOps):与 AI Agent 同级的画布操作能力
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasAgentOp =
|
||||||
|
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||||
|
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||||
|
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||||
|
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||||
|
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||||
|
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||||
|
| { type: "select_nodes"; ids: string[] }
|
||||||
|
| { type: "run_generation"; nodeId: string; mode?: CanvasGenerationMode; prompt?: string };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 资源:插件节点作为上游输入被消费时输出什么(接入生成/引用体系)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||||
|
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI 生成:插件直接复用宿主的模型/密钥配置发起生成(生图/生视频/生文本/生音频)
|
||||||
|
//
|
||||||
|
// 插件本身拿不到 API Key 与模型配置,这些能力由宿主注入。前置/系统提示词由
|
||||||
|
// 插件自行拼进 prompt(宿主不感知),因此不同插件可各自定制自己的提示词策略。
|
||||||
|
// 若宿主 AI 配置未就绪,会抛错(并由宿主提示用户去配置),插件用 try/catch 处理即可。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 生成公共可选项;references 为图生图/图生视频的参考图(dataURL 或可访问 URL)
|
||||||
|
export type GenerateOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
references?: string[];
|
||||||
|
model?: string; // 指定模型(取自 ai.listModels 的 value);缺省用宿主当前配置
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateImageOptions = GenerateOptions & {
|
||||||
|
count?: number; // 期望生成张数(宿主会按模型上限裁剪)
|
||||||
|
size?: string; // 形如 "1024x1024" / "auto";缺省用宿主当前配置
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateImageResult = {
|
||||||
|
// 生成图的 dataURL(已可直接作为 <img src> 或 three 纹理使用)
|
||||||
|
images: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateVideoOptions = GenerateOptions & {
|
||||||
|
size?: string;
|
||||||
|
seconds?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateVideoResult = {
|
||||||
|
url: string; // 视频可访问 URL
|
||||||
|
mimeType: string;
|
||||||
|
width?: number;
|
||||||
|
height?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateTextOptions = {
|
||||||
|
signal?: AbortSignal;
|
||||||
|
model?: string;
|
||||||
|
system?: string; // 附加系统提示词(拼在宿主系统提示之后)
|
||||||
|
onDelta?: (text: string) => void; // 流式增量回调
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateTextResult = {
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 一个可选模型:value 传回给 generateXxx({ model }),label 用于展示
|
||||||
|
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||||
|
export type ModelOption = { value: string; label: string };
|
||||||
|
|
||||||
|
// 宿主注入的 AI 生成能力,挂在 ctx.ai 下。任何插件均可调用。
|
||||||
|
export type CanvasPluginAi = {
|
||||||
|
generateImage: (prompt: string, options?: GenerateImageOptions) => Promise<GenerateImageResult>;
|
||||||
|
generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise<GenerateVideoResult>;
|
||||||
|
generateText: (prompt: string, options?: GenerateTextOptions) => Promise<GenerateTextResult>;
|
||||||
|
// 列出某能力下用户已配置的可选模型;不传能力则返回全部
|
||||||
|
listModels: (capability?: ModelCapability) => ModelOption[];
|
||||||
|
// 该能力当前默认选中的模型 value(可作为下拉框初始值)
|
||||||
|
defaultModel: (capability: ModelCapability) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type PluginStorage = {
|
||||||
|
get: <T = unknown>(key: string) => Promise<T | null>;
|
||||||
|
set: (key: string, value: unknown) => Promise<void>;
|
||||||
|
remove: (key: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeContext = {
|
||||||
|
// 自身数据
|
||||||
|
node: CanvasNodeData;
|
||||||
|
theme: CanvasTheme;
|
||||||
|
scale: number;
|
||||||
|
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
|
||||||
|
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||||
|
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||||
|
// 图访问
|
||||||
|
getNode: (id: string) => CanvasNodeData | null;
|
||||||
|
getNodes: () => CanvasNodeData[];
|
||||||
|
getConnections: () => CanvasConnection[];
|
||||||
|
getUpstream: () => CanvasNodeData[];
|
||||||
|
getDownstream: () => CanvasNodeData[];
|
||||||
|
// 画布操作(复用 Agent 指令集)
|
||||||
|
applyOps: (ops: CanvasAgentOp[]) => void;
|
||||||
|
// 节点间/插件间通信
|
||||||
|
emit: (event: string, payload?: unknown) => void;
|
||||||
|
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||||
|
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
|
||||||
|
ai: CanvasPluginAi;
|
||||||
|
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
|
||||||
|
openPanel: () => void;
|
||||||
|
closePanel: () => void;
|
||||||
|
// 插件私有持久化,按插件 id 命名空间隔离
|
||||||
|
storage: PluginStorage;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 节点定义:内置节点与插件节点统一走这套结构
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type CanvasNodeToolbarItem = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
label: string;
|
||||||
|
icon: ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
danger?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeContentProps = { ctx: CanvasNodeContext };
|
||||||
|
export type CanvasNodePanelProps = { ctx: CanvasNodeContext; onClose: () => void };
|
||||||
|
|
||||||
|
// 复用宿主内置生成面板(与图片/视频/文本节点同一个组件:模型选择、参数设置、
|
||||||
|
// 提示词库、运行/停止状态全部一致)。声明它即可获得完整生成体验,无需自写面板。
|
||||||
|
export type CanvasBuiltinPanelConfig = {
|
||||||
|
mode: "image" | "video" | "text" | "audio"; // 生成类型,决定面板里的模型/设置项
|
||||||
|
// 提交给模型前自动拼在用户提示词前面的固定前缀(如全景图的 equirectangular 约束)
|
||||||
|
promptPrefix?: string;
|
||||||
|
// true(默认)时生成结果写回本节点自身 metadata.content;false 则按内置逻辑生成到下游新节点
|
||||||
|
writeBackToSelf?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasNodeDefinition = {
|
||||||
|
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||||
|
title: string;
|
||||||
|
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||||
|
description?: string;
|
||||||
|
defaultSize: { width: number; height: number };
|
||||||
|
defaultMetadata?: CanvasNodeMetadata;
|
||||||
|
minimapColor?: string;
|
||||||
|
showInCreateMenu?: boolean; // 默认 true
|
||||||
|
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||||
|
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||||
|
// 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图);选中时仍显示选中描边
|
||||||
|
transparentBackground?: boolean;
|
||||||
|
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||||
|
// 复用宿主内置生成面板;与自定义 Panel 二选一(同时提供时优先 Panel)
|
||||||
|
useBuiltinPanel?: CanvasBuiltinPanelConfig;
|
||||||
|
// 为 true 时:宿主自动在工具条加「交互 ⇄ 移动」开关,并按 metadata.interactive 控制内容层指针事件。
|
||||||
|
// 默认(interactive 未设/为 false)为「移动」态:内容不吃指针,拖动整块移动节点;
|
||||||
|
// 切到「交互」态后内容可点击/拖拽(如全景转视角、iframe 操作)。适合内部有交互的展示型节点。
|
||||||
|
interactionToggle?: boolean;
|
||||||
|
// 配合 interactionToggle:返回 true 表示当前节点内容「强制可交互」(如编辑态),
|
||||||
|
// 此时忽略 interactive 标志、始终允许操作,并隐藏移动/交互开关。缺省视为 false。
|
||||||
|
forceInteractive?: (node: CanvasNodeData) => boolean;
|
||||||
|
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||||
|
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||||
|
// 渲染
|
||||||
|
Content?: ComponentType<CanvasNodeContentProps>;
|
||||||
|
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板(自定义)
|
||||||
|
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
|
||||||
|
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 插件运行时与插件包
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// 插件启动时(setup)可访问的应用能力
|
||||||
|
export type CanvasPluginApp = {
|
||||||
|
version: string;
|
||||||
|
emit: (event: string, payload?: unknown) => void;
|
||||||
|
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||||
|
// 注入插件样式,返回移除函数;传 key 时同 key 覆盖旧样式
|
||||||
|
injectCSS: (css: string, key?: string) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 宿主注入的运行时(工厂形式插件的入参),内含宿主 React 实例避免双 React
|
||||||
|
export type PluginRuntime = CanvasPluginApp & {
|
||||||
|
React: typeof import("react");
|
||||||
|
jsx: typeof import("react").createElement;
|
||||||
|
Fragment: typeof import("react").Fragment;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 插件包(默认导出对象,或返回它的工厂函数)
|
||||||
|
export type CanvasPlugin = {
|
||||||
|
id: string; // 唯一,kebab-case
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
minAppVersion?: string;
|
||||||
|
css?: string; // 插件样式,启用时自动注入、卸载/禁用时自动清理
|
||||||
|
nodes: CanvasNodeDefinition[];
|
||||||
|
setup?: (app: CanvasPluginApp) => void | (() => void);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CanvasPluginFactory = (runtime: PluginRuntime) => CanvasPlugin;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": ".",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false
|
||||||
|
},
|
||||||
|
"include": ["src", "build.mjs"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# 便利贴节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:彩色便利贴,可换色、编辑,并演示用 `ctx.applyOps` 衍生一个连到自己的文本节点(节点间交互示例)。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/sticky-note.js,并同步到 web/public/plugins/sticky-note.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/sticky-note.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-sticky-note",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "canvas-plugin-sticky-note",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"../sdk": {
|
||||||
|
"name": "@infinite-canvas/plugin-sdk",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dev": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"esbuild": "^0.25.0",
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||||
|
"resolved": "../sdk",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||||
|
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "canvas-plugin-sticky-note",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Infinite Canvas 便利贴节点插件",
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"dev": "node build.mjs --watch",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||||
|
"@types/react": "19.1.12",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// 便利贴节点:纯展示便利贴——整块可拖动、双击编辑、右上角自选颜色。
|
||||||
|
// 不再声明 resource(避免宿主在右上角显示「文本N」资源角标),也不再衍生节点。
|
||||||
|
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||||
|
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||||
|
|
||||||
|
// 预设便签色(点选切换),并额外提供自定义取色
|
||||||
|
const PRESET_COLORS = ["#fde68a", "#fca5a5", "#fdba74", "#a7f3d0", "#bfdbfe", "#ddd6fe", "#f9a8d4", "#e7e5e4"];
|
||||||
|
const DEFAULT_COLOR = PRESET_COLORS[0];
|
||||||
|
|
||||||
|
function StickyNoteContent({ ctx }: CanvasNodeContentProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
|
// 取色时的本地预览色:仅本组件即时重渲染,避免每次都写宿主 store
|
||||||
|
const [draftColor, setDraftColor] = useState<string | null>(null);
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const commitTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// pluginColor 是插件自定义 metadata 字段(读出为 unknown,按需断言)
|
||||||
|
const committedColor = (ctx.node.metadata?.pluginColor as string | undefined) || DEFAULT_COLOR;
|
||||||
|
const color = draftColor ?? committedColor;
|
||||||
|
const content = (ctx.node.metadata?.content as string | undefined) || "";
|
||||||
|
|
||||||
|
// 点击便利贴外部时:退出编辑并收起调色板。
|
||||||
|
// 监听 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上 preventDefault(会抑制
|
||||||
|
// 兼容 mousedown 事件),所以必须用 pointerdown;capture 阶段又能先于宿主的 stopPropagation
|
||||||
|
// 触发,避免「按 Esc 能退出、点别处退不出」。
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editing && !paletteOpen) return;
|
||||||
|
const onDocDown = (e: PointerEvent) => {
|
||||||
|
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||||
|
setEditing(false);
|
||||||
|
setPaletteOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("pointerdown", onDocDown, true);
|
||||||
|
return () => document.removeEventListener("pointerdown", onDocDown, true);
|
||||||
|
}, [editing, paletteOpen]);
|
||||||
|
|
||||||
|
const pickColor = (next: string) => ctx.updateMetadata({ pluginColor: next });
|
||||||
|
|
||||||
|
// 连续取色(系统取色器拖动时 onChange 高频触发)会不停调用 updateMetadata,
|
||||||
|
// 而宿主每次都会整表重建 + 全画布重渲染 + 持久化,导致卡顿。
|
||||||
|
// 这里先本地预览(setDraftColor),再节流提交到宿主。
|
||||||
|
const previewColor = (next: string) => {
|
||||||
|
setDraftColor(next);
|
||||||
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||||
|
commitTimerRef.current = window.setTimeout(() => {
|
||||||
|
commitTimerRef.current = null;
|
||||||
|
pickColor(next);
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 立即提交(点击预设色、取色器关闭时用):清掉待提交的节流并写入。
|
||||||
|
const commitColor = (next: string) => {
|
||||||
|
if (commitTimerRef.current) {
|
||||||
|
clearTimeout(commitTimerRef.current);
|
||||||
|
commitTimerRef.current = null;
|
||||||
|
}
|
||||||
|
setDraftColor(next);
|
||||||
|
pickColor(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 宿主里的颜色一旦真正变化(提交完成 / 撤销重做),清掉本地预览,回到 store 为准。
|
||||||
|
// 拖动取色期间 committedColor 不变,draftColor 得以保留,故不影响即时预览。
|
||||||
|
useEffect(() => {
|
||||||
|
setDraftColor(null);
|
||||||
|
}, [committedColor]);
|
||||||
|
|
||||||
|
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
|
||||||
|
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
data-canvas-no-zoom
|
||||||
|
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", background: color, borderRadius: 16, padding: 14, boxSizing: "border-box", cursor: editing ? "text" : "move", boxShadow: "inset 0 1px 0 rgba(255,255,255,.45)" }}
|
||||||
|
onDoubleClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 右上角:当前颜色小圆点,点开后自选颜色(预设 + 自定义) */}
|
||||||
|
<div style={{ position: "absolute", top: 8, right: 8, zIndex: 5 }} onMouseDown={stop} onDoubleClick={stop}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="选择颜色"
|
||||||
|
onClick={() => setPaletteOpen((v) => !v)}
|
||||||
|
style={{ width: 22, height: 22, borderRadius: "50%", border: "2px solid rgba(0,0,0,.25)", background: color, cursor: "pointer", padding: 0, boxShadow: "0 1px 3px rgba(0,0,0,.2)" }}
|
||||||
|
/>
|
||||||
|
{paletteOpen ? (
|
||||||
|
<div style={{ position: "absolute", top: 28, right: 0, display: "grid", gridTemplateColumns: "repeat(4, 20px)", gap: 6, padding: 8, borderRadius: 12, background: "#fff", boxShadow: "0 8px 24px rgba(0,0,0,.18)" }}>
|
||||||
|
{PRESET_COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
type="button"
|
||||||
|
title={c}
|
||||||
|
onClick={() => {
|
||||||
|
commitColor(c);
|
||||||
|
setPaletteOpen(false);
|
||||||
|
}}
|
||||||
|
style={{ width: 20, height: 20, borderRadius: "50%", border: c === color ? "2px solid #1c1917" : "2px solid rgba(0,0,0,.12)", background: c, cursor: "pointer", padding: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 自定义取色:点击彩环唤起系统取色器。拖动时本地预览 + 节流提交,松开(blur)时立即提交 */}
|
||||||
|
<label title="自定义颜色" style={{ position: "relative", width: 20, height: 20, borderRadius: "50%", cursor: "pointer", background: "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)", border: "2px solid rgba(0,0,0,.12)", boxSizing: "border-box" }}>
|
||||||
|
<input type="color" value={color} onChange={(e) => previewColor(e.target.value)} onBlur={(e) => commitColor(e.target.value)} style={{ position: "absolute", inset: 0, opacity: 0, width: "100%", height: "100%", border: "none", padding: 0, cursor: "pointer" }} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 内容区:双击进入编辑;非编辑态整块可直接拖动移动节点 */}
|
||||||
|
{editing ? (
|
||||||
|
<textarea
|
||||||
|
autoFocus
|
||||||
|
value={content}
|
||||||
|
placeholder="输入便利贴内容…(点击别处或按 Esc 退出编辑)"
|
||||||
|
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||||
|
onBlur={() => setEditing(false)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={stop}
|
||||||
|
onPointerDown={stop}
|
||||||
|
onWheel={stop}
|
||||||
|
style={{ flex: 1, width: "100%", resize: "none", border: "none", outline: "none", background: "transparent", color: "#1c1917", fontSize: 15, lineHeight: 1.5, fontFamily: "inherit" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ flex: 1, whiteSpace: "pre-wrap", overflow: "hidden", color: content ? "#1c1917" : "rgba(28,25,23,.45)", fontSize: 15, lineHeight: 1.5, userSelect: "none", paddingRight: 22 }}>{content || "双击编辑便利贴"}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
id: "sticky-note",
|
||||||
|
name: "便利贴节点",
|
||||||
|
version: "1.1.0",
|
||||||
|
description: "可自选颜色、双击编辑、拖动即可移动的便利贴",
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: "sticky-note:note",
|
||||||
|
title: "便利贴",
|
||||||
|
icon: "📌",
|
||||||
|
description: "彩色便利贴",
|
||||||
|
defaultSize: { width: 240, height: 200 },
|
||||||
|
defaultMetadata: { content: "", pluginColor: DEFAULT_COLOR },
|
||||||
|
minimapColor: "#f59e0b",
|
||||||
|
// 纯记事节点:不弹出下方生成面板(默认会是「生成图片」的提示词面板)
|
||||||
|
hidePanel: true,
|
||||||
|
Content: StickyNoteContent,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# SVG 节点插件
|
||||||
|
|
||||||
|
Infinite Canvas 画布节点插件:编辑与渲染 SVG,无自身内容时自动取上游文本节点里的 SVG 源码。
|
||||||
|
|
||||||
|
## 构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # 产物 dist/svg.js,并同步到 web/public/plugins/svg.js
|
||||||
|
npm run dev # watch
|
||||||
|
```
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/svg.js`(或托管后的公网 URL)。
|
||||||
|
|
||||||
|
插件契约见 `plugins/canvas/README.md`。
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||||
|
|
||||||
|
await buildPlugin(import.meta.url);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user