Compare commits

...

49 Commits

Author SHA1 Message Date
HouYunFei 2f9776bee3 chore: bump version to v0.8.1 2026-07-16 15:12:02 +08:00
HouYunFei 4beadc6161 feat(markdown-node): improve rendering performance and user interaction by caching parsed Markdown and optimizing DOM updates 2026-07-16 15:11:48 +08:00
HouYunFei 13c508bc34 feat(svg-node): enhance SVG node with transparent background rendering and improved editing experience 2026-07-16 14:13:43 +08:00
HouYunFei 5f627d5baf feat(sticky-note): update sticky note plugin with customizable colors, improved editing experience, and enhanced drag-and-drop functionality 2026-07-16 13:57:47 +08:00
HouYunFei 3f179a607a feat(panorama): enhance 3D panorama node with AI generation support and unified interaction toggle 2026-07-16 13:21:25 +08:00
HouYunFei 2d2c7e3d5e feat(plugin-manager): add upgrade notification for installed plugins and improve version handling 2026-07-16 10:36:41 +08:00
HouYunFei 3f623ea70c feat(html-node): enhance HTML node plugin with interactive toolbar and improved editor functionality 2026-07-15 17:51:39 +08:00
HouYunFei 7ac2ab351d chore(release): bump version to v0.8.0 and update CHANGELOG 2026-07-15 16:00:17 +08:00
HouYunFei b6e75c225e Merge branch 'dev'
# Conflicts:
#	.gitignore
#	CHANGELOG.md
#	web/bun.lock
2026-07-15 15:32:32 +08:00
HouYunFei 75aafc115f feat(plugin-sdk): add TypeScript SDK for Infinite Canvas plugins with automatic JSX and build support 2026-07-15 15:31:48 +08:00
HouYunFei 8d3f244524 feat(toolbar): add extension node management and close on outside click 2026-07-15 13:10:53 +08:00
HouYunFei 88510223eb feat(plugin): add local plugin discovery and management with cache busting 2026-07-15 12:55:59 +08:00
HouYunFei 3a4a4a6772 fix(deps): sync bun.lock with package.json for streamdown transitive deps 2026-07-15 11:59:59 +08:00
HouYunFei ee8f126da7 feat(id): replace crypto.randomUUID with nanoid for unique ID generation 2026-07-15 11:55:36 +08:00
HouYunFei eef4d96787 feat(plugin): implement canvas node plugin system with dynamic registration and remote installation 2026-07-15 11:47:25 +08:00
HouYunFei f0db29d8e3 feat(channel): add customizable JS model-call scripts with split-panel editor
- Run user-authored async scripts per model with flat locals (prompt,
  images, messages, params, model, baseUrl, apiKey, http, request, poll,
  sleep, signal, onDelta); empty script falls back to system default.
- Wire image/video/audio/text request entrypoints through the plugin
  runtime, keeping default handlers unchanged.
- Split-panel script editor: variable/return docs on the left, CodeMirror
  JS editor on the right; title shows capability - model.
- Provide OpenAI and Gemini template buttons per capability; text template
  uses the /responses API; image template branches text2img vs img2img.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:24:34 +08:00
HouYunFei 3963c6eebf feat(model): enhance model configuration and script handling for audio and video capabilities 2026-07-15 10:13:37 +08:00
HouYunFei c57f7d61a7 feat(model): enhance model configuration and script handling for audio and video capabilities 2026-07-15 10:13:32 +08:00
HouYunFei a4dcc679c9 feat(version): bump version to 0.7.0 2026-07-14 17:37:57 +08:00
HouYunFei b16263eb43 feat(version): bump version to 0.2.0 and update image ratio parsing functions 2026-07-14 17:37:51 +08:00
HouYunFei a5824f509e feat(agent): enhance local Agent connection handling with silent mode and update UI instructions 2026-07-14 17:08:20 +08:00
HouYunFei 7e0288d461 feat(docs): refine Infinite Canvas usage instructions and clarify local Agent connection process 2026-07-14 16:24:22 +08:00
HouYunFei 228ca29c82 feat(docs): update Infinite Canvas usage instructions and streamline local Agent connection process 2026-07-13 17:23:36 +08:00
HouYunFei 301fbce472 feat(canvas): add double-click functionality to create nodes from blank canvas area 2026-07-13 10:43:37 +08:00
HouYunFei ebd8ae2de7 feat(agent): clarify local Agent connection instructions for Codex plugin and manual MCP usage 2026-07-09 17:49:40 +08:00
HouYunFei 864ce4101a feat(agent): implement streamdown for real-time message rendering in chat 2026-07-09 17:16:35 +08:00
HouYunFei 25e1d173e8 feat(docs): add local development and canvas data structure documentation 2026-07-09 17:14:46 +08:00
HouYunFei 52a5e9e33a feat(agent): update documentation for new site tools and changelog guidelines 2026-07-09 16:54:48 +08:00
HouYunFei 2adbe9e43a feat(agent): add site tools support for canvas project listing and media generation 2026-07-09 16:53:09 +08:00
HouYunFei 7b347729ec feat(agent): implement global Agent panel with site navigation and interrupt functionality 2026-07-09 16:19:32 +08:00
HouYunFei 387d8be961 feat(canvas): enhance group node functionality with improved click and drag behavior 2026-07-09 13:31:13 +08:00
HouYunFei ec0e8c4e24 feat(canvas): add group node support with drag-and-drop functionality 2026-07-09 12:54:36 +08:00
HouYunFei abe3be58a6 feat(canvas): add group node support with drag-and-drop functionality 2026-07-09 11:48:23 +08:00
HouYunFei 050b36fad1 chore(changelog): add release notes for v0.6.0 2026-07-09 10:42:13 +08:00
HouYunFei 668c3adb52 chore(changelog): add release notes for v0.6.0 2026-07-09 10:21:07 +08:00
HouYunFei f2a36e86b2 fix(video): enhance video generation error handling and URL retrieval 2026-07-08 22:23:18 +08:00
HouYunFei d747527e2a Merge branch 'fork/beetle0915/fix/ime-enter-composition' into dev 2026-07-08 16:30:38 +08:00
HouYunFei 798d06b662 Merge branch 'fork/1797127235/fix/canvas-mode-model-switch' into dev
# Conflicts:
#	CHANGELOG.md
#	docs/content/docs/progress/pending-test.mdx
#	web/src/components/canvas/canvas-config-node-panel.tsx
#	web/src/components/canvas/canvas-node-prompt-panel.tsx
2026-07-08 16:23:12 +08:00
HouYunFei 257dab5bac fix: resolve issue with Gemini image format not passing aspect ratio configuration 2026-07-08 16:18:12 +08:00
HouYunFei 81144ea009 fix: resolve issue with Gemini image format not passing aspect ratio configuration 2026-07-08 15:11:18 +08:00
beetle0915 bac571ef4e Fix Enter handling during IME composition 2026-07-07 08:24:34 +08:00
1797127235 965d416aeb fix(canvas): switch to correct default model when changing generation mode 2026-07-06 10:33:52 +08:00
HouYunFei 3080d6da10 feat: enhance Codex agent connection handling with automatic thread recovery and improved error logging 2026-07-05 23:40:54 +08:00
HouYunFei 6113986637 refactor: remove agent mode switch and streamline local agent usage in the canvas assistant panel 2026-07-05 17:04:02 +08:00
HouYunFei ca5faa1f4e feat: add GitHub Pages workflow for automatic deployment on version tag push 2026-07-05 16:51:27 +08:00
HouYunFei 0b69dfce46 feat: add support for draggable grid lines in image slicing, with options to add, delete, and reset horizontal/vertical lines 2026-07-05 16:08:23 +08:00
HouYunFei 65a91b22e1 feat: add Codex App plugin support with dedicated configuration page and connection settings 2026-07-05 15:12:42 +08:00
HouYunFei 9dd0dc28b2 refactor: remove credit cost calculations and related UI elements from canvas node panels 2026-07-05 13:44:42 +08:00
HouYunFei 522153b3e6 fix: resolve issue with image retry results not persisting after page refresh in the image workbench 2026-07-05 12:46:51 +08:00
158 changed files with 27070 additions and 2515 deletions
+52
View File
@@ -0,0 +1,52 @@
name: GitHub Pages
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
working-directory: web
run: bun install
- name: Build
working-directory: web
env:
VITE_BASE: /${{ github.event.repository.name }}/
run: bun run build
- name: Add SPA fallback
run: cp web/dist/index.html web/dist/404.html
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: web/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+47
View File
@@ -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
+7
View File
@@ -8,3 +8,10 @@ data/
*.tsbuildinfo
.DS_Store
.idea
web/dist
**/dist
web/public/plugins
plugins/canvas/*/dist
plugins/canvas/*/node_modules
web/public/plugins
+3 -15
View File
@@ -8,7 +8,7 @@
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
- 项目尚未上线,不需要兼容旧数据;本地存储结构调整时直接按新设计修改,不写旧字段兼容数据迁移兜底,除非用户明确要求。
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
- 不要改无关文件,不要顺手重构。
- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。
@@ -19,22 +19,11 @@
- 补充时写成明确、可执行的规则,避免只写模糊描述。
- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。
## 后端规范
- 后端使用 Go + Gin + GORM。
- `handler/` 只处理 HTTP 入参、调用 service、返回 `OK` / `Fail`
- `service/` 放业务逻辑、默认值、校验、时间、ID、鉴权等处理。
- `repository/` 只做数据库访问和 GORM 查询。
- `model/` 只定义数据结构、枚举和简单模型方法。
- 列表接口优先沿用 `model.Query``Normalize`、分页和标签筛选方式。
- 业务接口保持 `{ code, data, msg }` 的响应结构。
- 新增数据表时同步更新 `docs/backend-database.md`
## 前端规范
- 前端使用 Vite、React、React Router、TypeScript、Ant Design、Tailwind、Zustand。
- 编写 Ant Design 相关代码时,参考 https://ant.design/llms-full.txt 理解组件 API、示例和设计规范,并优先结合项目当前 antd 版本与既有写法。
- API 请求统一放在 `web/src/services/api/`
- 外部服务请求统一放在 `web/src/services/api/`,由浏览器前端直连,不假设存在项目后端
- 全局或跨页面状态优先放在 `web/src/stores/`
- 已经放在全局 store 或全局 hook 中的状态/动作,组件需要时直接使用对应 store/hook,不要为了“纯组件”层层透传 props;避免一个组件传递过多参数。
- 全局组件、全局常量、全局配置等全局性质的内容不要作为 props 或参数层层传递;哪里需要就在哪里直接从对应全局入口获取。
@@ -73,10 +62,9 @@
- 后续待办写到 `docs/content/docs/progress/todo.mdx`
- 已实现但还需要用户测试确认的事项写到 `docs/content/docs/progress/pending-test.mdx`
- `docs/content/docs/progress/pending-test.mdx` 用来记录这个版本实际做了哪些可测试变更;`CHANGELOG.md``Unreleased` 只保留对这些变更的版本级归纳,避免逐条照搬实现细节。
- 每次重大改动(新增/调整/删除功能、接口或工具,影响用户可感知行为)完成后,都要在 `CHANGELOG.md``Unreleased` 追加一条记录,按 `[新增]` / `[调整]` / `[修复]` / `[优化]` 前缀分类,用一句中文归纳;纯内部重构、格式化、无用户可感知影响的小改动可不记。
- 每次 todo 事项完成后,先从 `docs/content/docs/progress/todo.mdx` 移到 `docs/content/docs/progress/pending-test.mdx`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/content/docs/overview/features.mdx`
- 每次任务完成前,都要根据实际变更检查并更新 `docs/content/docs/progress/todo.mdx``docs/content/docs/progress/pending-test.mdx`;如果功能或待办没有变化,也要确认无需修改。
- 接口响应规则写到 `docs/content/docs/backend/api-response.mdx`
- 数据库结构写到 `docs/content/docs/backend/backend-database.mdx`
- 文档不要写过期日期;除非用户明确要求记录具体时间。
## 发版本流程
+52 -1
View File
@@ -2,9 +2,60 @@
## Unreleased
## 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.0 - 2026-07-14
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
+ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
+ [修复] 修复 Gemini 格式生图时因内置比例列表触发误报导致生成失败的问题。
## v0.6.0 - 2026-07-09
+ [新增] 新增Codex App插件支持。
+ [修复] 修复前端 TypeScript 构建报错
+ [新增] 配置与用户偏好新增独立页面和 Codex 连接配置 Tab
+ [新增] 新增GitHub Pages 前端静态站点发布 workflow。
+ [新增] 图片切图支持等分线直接拖拽调整,并可新增、删除和重置横向 / 纵向切图线。
+ [调整] Docker 运行镜像改为 nginx 静态托管。
+ [调整] 移除网站Agent模式,专注于连接Codex Agent操作画布
+ [修复] 修复生图工作台重试成功结果刷新后丢失的问题。
+ [修复] 修复 Gemini 调用格式生图未传递尺寸比例配置的问题。
+ [修复] 修复前端 TypeScript 构建报错。
+ [修复] 修复画布生成配置切换文本/视频/音频模式时模型仍显示为生图模型的问题。
+ [修复] 兼容中转站视频任务直接返回视频 URL 且没有 `/content` 接口的情况,并优化失败原因展示。
## v0.5.0 - 2026-07-05
+20 -23
View File
@@ -8,16 +8,24 @@
<a href="https://linux.do/"><img src="https://img.shields.io/badge/Linux.do-Community-2b6de8?style=flat-square" alt="Linux.do"></a>
<a href="https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas"><img src="https://img.shields.io/badge/Render-Deploy-46e3b7?style=flat-square&logo=render&logoColor=111111" alt="Deploy to Render"></a>
<a href="https://github.com/basketikun/infinite-canvas"><img src="https://img.shields.io/github/stars/basketikun/infinite-canvas?style=flat-square&logo=github" alt="GitHub stars"></a>
<a href="VERSION"><img src="https://img.shields.io/badge/version-v0.2.0-2563eb?style=flat-square" alt="Version"></a>
<a href="https://github.com/basketikun/infinite-canvas/tags"><img src="https://img.shields.io/github/v/tag/basketikun/infinite-canvas?style=flat-square&label=version" alt="Version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-f97316?style=flat-square" alt="License"></a>
<a href="https://vite.dev/"><img src="https://img.shields.io/badge/Vite-7-646cff?style=flat-square&logo=vite&logoColor=white" alt="Vite"></a>
<a href="https://reactrouter.com/"><img src="https://img.shields.io/badge/React_Router-7-ca4245?style=flat-square&logo=reactrouter&logoColor=white" alt="React Router"></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50077?utm_source=trendshift-badge&amp;utm_medium=badge&amp;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>
</p>
<p align="center">
<a href="docs/content/docs/overview/quick-start.mdx">快速开始</a> · <a href="docs/content/docs/overview/features.mdx">功能介绍</a> · <a href="docs/content/docs/overview/render.mdx">Render 部署</a> · <a href="docs/content/docs/overview/docker.mdx">Docker 部署</a> · <a href="docs/content/docs/canvas/canvas-node-manual.mdx">画布节点操作手册</a> · <a href="docs/content/docs/canvas/canvas-shortcuts.mdx">画布快捷键</a> · <a href="CLA.md">贡献者协议</a> · <a href="SECURITY.md">漏洞提交</a> · <a href="docs/content/docs/progress/todo.mdx">待办事项</a> · <a href="canvas-agent/README.md">本地 Canvas Agent</a> · <a href="plugins/infinite-canvas">Codex app 插件</a>
</p>
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
> [!CAUTION]
> 项目目前处于开发阶段,不保证历史数据兼容。各种数据库结构和存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用。
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用。
>
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
@@ -34,16 +42,12 @@
如果你在为担心没有合适的生图API来发愁,可以查看该免费生图项目:[chatgpt2api](https://github.com/basketikun/chatgpt2api)
## 技术栈
- 前端:Vite、React、React Router、TypeScript、Tailwind CSS、Ant Design、Zustand、TanStack Query。
- 前端:提示词库、WebDAV 和 AI 接口都由浏览器前端直连。
- 部署:静态站点托管或 Docker。
## 快速开始
AI API Key、Base URL、画布、素材和生成记录默认保存在浏览器本地。
### 本地开发
```bash
git clone git@github.com:basketikun/infinite-canvas.git
cd infinite-canvas
@@ -52,11 +56,12 @@ bun install
bun run dev
```
Docker 运行
### Docker 运行
```bash
docker build -t infinite-canvas .
docker run --rm -p 3000:3000 infinite-canvas
git clone git@github.com:basketikun/infinite-canvas.git
cd infinite-canvas
docker compose up -d
```
运行后默认端口3000,可访问 `http://localhost:3000`
@@ -95,19 +100,11 @@ https://canvas.best?apiKey={key}&baseUrl={address}
</tr>
</table>
## 文档
## 联系方式
- [快速开始](docs/content/docs/overview/quick-start.mdx)
- [功能介绍](docs/content/docs/overview/features.mdx)
- [Render 部署](docs/content/docs/overview/render.mdx)
- [Docker 部署](docs/content/docs/overview/docker.mdx)
- [画布节点操作手册](docs/content/docs/canvas/canvas-node-manual.mdx)
- [画布快捷键](docs/content/docs/canvas/canvas-shortcuts.mdx)
- [贡献者协议](CLA.md)
- [漏洞提交](SECURITY.md)
- [待办事项](docs/content/docs/progress/todo.mdx)
- [本地 Canvas Agent](canvas-agent/README.md)
- [Codex app 插件](plugins/infinite-canvas)
项目定制二次开发需求 / 生图 API 需求可联系。
邮箱:1844025705@qq.com · QQ1844025705
## 赞助支持
+16
View File
@@ -31,6 +31,22 @@ Please include:
## 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:
- Cross-site scripting or token exfiltration in the web app.
+1 -1
View File
@@ -1 +1 @@
v0.5.0
v0.8.1
+16 -2
View File
@@ -40,6 +40,20 @@ Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接
如果希望 Codex 终端能直接操作画布,需要先把 Canvas Agent 注册成 Codex MCP。
直接运行 `npx -y @basketikun/canvas-agent` 只启动本地 Agent 服务,不会安装 MCP,也不会增加 Codex 工具上下文。只有安装 Codex app 插件,或手动执行 `codex mcp add` 后,`infinite-canvas` 工具才会进入 Codex 上下文;由于工具较多,不使用时建议移除。
通过插件安装时移除插件:
```bash
codex plugin remove infinite-canvas
```
手动添加 MCP 时移除 MCP
```bash
codex mcp remove infinite-canvas
```
### Codex app 插件
仓库内提供了 Codex app 插件:`plugins/infinite-canvas`。在 Codex app 中添加本仓库的 marketplace 后,可以安装 `Infinite Canvas` 插件;插件会注册同一个 `infinite-canvas` MCP,并带上画布操作说明。
@@ -52,13 +66,13 @@ codex plugin marketplace add "$(pwd)"
codex plugin add infinite-canvas@infinite-canvas-local
```
插件默认通过 npm 启动 MCP
插件默认通过 npm 启动 MCP;这个命令只提供 MCP 工具,不会把 MCP 写入全局配置,也不会在退出时自动卸载
```bash
npx -y @basketikun/canvas-agent mcp
```
使用时可以直接在 Codex 里说“打开 Infinite Canvas”,插件会优先启动本地画布和本地 Agent,读取 Local URL 和 Connect token,然后直接打开画布网页地址新建并连接画布。如果自动连接失败,再检查本地画布服务和 Canvas Agent 是否都已启动
使用时可以直接在 Codex 里说“打开 Infinite Canvas”,插件会启动本地 Agent,读取 Local URL 和 Connect token,然后在右侧打开 `https://canvas.best/` 并自动新建、连接画布;只有明确要求使用本地项目时才会启动本地前端
Canvas Agent 启动后,给 Codex 添加 MCP
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@basketikun/canvas-agent",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+42 -9
View File
@@ -30,13 +30,26 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
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);
const threadId = await ensureCodexThread(codexApp, options);
await codexApp.startTurn(threadId, prompt, files);
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 {
@@ -96,14 +109,20 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) {
pipeJsonLines(child, emit, "claude");
}
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
if (options.threadId) {
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;
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);
@@ -112,6 +131,10 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions)
return codexThreadId;
}
function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error));
}
class CodexAppClient {
private nextId = 1;
private buffer = "";
@@ -182,6 +205,16 @@ class CodexAppClient {
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 });
+19 -2
View File
@@ -7,6 +7,18 @@ 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>();
@@ -18,13 +30,14 @@ export class CanvasSession {
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" });
this.clients.set(clientId, res);
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);
this.clients.delete(clientId);
if (!statusOnly) this.clients.delete(clientId);
if (this.canvasState?.clientId === clientId) this.canvasState = null;
});
}
@@ -48,6 +61,10 @@ export class CanvasSession {
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);
+15 -21
View File
@@ -7,10 +7,10 @@ export const DEFAULT_PORT = 17371;
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
export const VERSION = readPackageVersion();
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 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。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
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 type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
export function loadConfig(create = false): CanvasAgentConfig {
try {
@@ -27,30 +27,28 @@ export function saveConfig(config: CanvasAgentConfig) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
export function ensureCanvasWorkspace(config: CanvasAgentConfig, canvasId: string) {
const id = safeSegment(canvasId || "default");
config.canvases ||= {};
const current = config.canvases[id];
export function ensureSiteWorkspace(config: CanvasAgentConfig) {
const current = config.workspace;
if (current?.workspacePath) {
fs.mkdirSync(resolveWorkspacePath(current.workspacePath), { recursive: true });
return { canvasId: id, ...current, workspacePath: resolveWorkspacePath(current.workspacePath) };
const workspacePath = resolveWorkspacePath(current.workspacePath);
fs.mkdirSync(workspacePath, { recursive: true });
return { ...current, workspacePath };
}
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id);
config.canvases[id] = { workspacePath };
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
config.workspace = { workspacePath };
fs.mkdirSync(workspacePath, { recursive: true });
saveConfig(config);
return { canvasId: id, workspacePath };
return { workspacePath };
}
export function updateCanvasWorkspace(config: CanvasAgentConfig, canvasId: string, patch: Partial<CanvasWorkspaceConfig>) {
const current = ensureCanvasWorkspace(config, canvasId);
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
const current = ensureSiteWorkspace(config);
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
const next = { ...current, ...patch, workspacePath };
config.canvases ||= {};
config.canvases[current.canvasId] = { 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 });
saveConfig(config);
return { canvasId: current.canvasId, ...config.canvases[current.canvasId] };
return config.workspace;
}
function resolveWorkspacePath(value: string) {
@@ -59,10 +57,6 @@ function resolveWorkspacePath(value: string) {
return path.resolve(value);
}
function safeSegment(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "default";
}
function readPackageVersion() {
try {
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
+23 -17
View File
@@ -1,8 +1,8 @@
import express, { type NextFunction, type Request, type Response } from "express";
import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
import { CanvasSession } from "./canvas-session.js";
import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.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() {
@@ -38,56 +38,60 @@ export function startHttpServer() {
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 = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
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 = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
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 || "");
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId });
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 = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
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 = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
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 || "");
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
updateSiteWorkspace(config, { activeThreadId: threadId });
} else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
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 });
@@ -99,7 +103,9 @@ export function startHttpServer() {
console.log("Infinite Canvas Agent");
console.log(`Local URL: ${config.url}`);
console.log(`Connect token: ${config.token}`);
console.log("Codex MCP: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
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");
});
}
+27
View File
@@ -7,6 +7,8 @@ const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
export const toolNames = [
"site_navigate",
"canvas_list_projects",
"canvas_get_state",
"canvas_get_selection",
"canvas_export_snapshot",
@@ -30,6 +32,13 @@ export const toolNames = [
"canvas_select_nodes",
"canvas_set_viewport",
"canvas_run_generation",
"workbench_image_get_config",
"workbench_image_generate",
"workbench_video_get_config",
"workbench_video_generate",
"prompts_search",
"assets_list",
"assets_add",
] as const;
export type ToolName = (typeof toolNames)[number];
@@ -77,6 +86,8 @@ const generationFlowSchema = z.object({
});
export const toolInputSchemas = {
site_navigate: z.object({ path: z.string() }),
canvas_list_projects: z.object({ keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
canvas_get_state: z.object({}).passthrough(),
canvas_get_selection: z.object({}).passthrough(),
canvas_export_snapshot: z.object({}).passthrough(),
@@ -100,9 +111,18 @@ export const toolInputSchemas = {
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
canvas_set_viewport: z.object({ viewport: viewportSchema }),
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
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_video_get_config: z.object({}).passthrough(),
workbench_video_generate: z.object({ prompt: z.string(), model: z.string().optional(), size: z.string().optional(), seconds: z.string().optional(), resolution: z.string().optional(), generateAudio: z.boolean().optional(), watermark: z.boolean().optional(), run: z.boolean().optional() }),
prompts_search: z.object({ keyword: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().optional(), pageSize: z.number().optional() }),
assets_list: z.object({ kind: z.enum(["all", "text", "image", "video"]).optional(), keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
assets_add: z.object({ kind: z.enum(["text", "image"]), title: z.string(), content: z.string().optional(), imageUrl: z.string().optional(), tags: z.array(z.string()).optional(), source: z.string().optional(), note: z.string().optional() }),
} satisfies Record<ToolName, z.AnyZodObject>;
export const toolDescriptions: Record<ToolName, string> = {
site_navigate: "跳转网站页面。path 可为 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image (生图工作台)、/video (视频创作台)、/prompts (提示词库)、/assets (我的素材)、/config (配置)。操作画布前若不在画布页,先用本工具打开画布。",
canvas_list_projects: "列出用户全部画布(仅标题、创建/更新时间、节点数、连线数,不含完整数据),支持 keyword 搜索和 page/pageSize 分页。返回的 id 可配合 site_navigate 跳转到 /canvas/:id 打开对应画布。",
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
canvas_get_selection: "读取当前网页画布选中的节点。",
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
@@ -126,4 +146,11 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_select_nodes: "设置当前选中节点。",
canvas_set_viewport: "调整画布视口。",
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、countrun 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermarkrun 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
assets_list: "列出用户「我的素材」,支持 kindtext/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
};
@@ -5,7 +5,7 @@ description: 画布本地存储、节点结构、媒体文件与清理机制
# 画布数据结构
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式。
## 当前存储位置
+1 -1
View File
@@ -4,7 +4,7 @@
"pages": [
"overview",
"canvas",
"backend",
"development",
"progress",
"business",
"support"
@@ -88,7 +88,7 @@ codex plugin add infinite-canvas@infinite-canvas-local
打开 Infinite Canvas 新建画布 并连接好Codex
```
插件会优先确认当前仓库的本地画布服务是否已经运行。如果端口被占用,它会检查监听进程是否属于当前项目,避免误连到其他项目。确认或启动画布后,插件会启动本地 Canvas Agent,并打开带连接参数的新建画布 URL
插件会启动本地 Canvas Agent,读取连接地址和 token,然后在右侧打开 `https://canvas.best/` 并自动新建、连接画布。只有明确要求使用本地项目时,插件才会启动本地前端
画布打开后可以继续让 Codex 操作画布:
@@ -125,10 +125,10 @@ bun install
bun run dev
```
然后启动本地 Agent。端口不是 `3000` 时,把 `CANVAS_URL` 换成真实本地画布地址
然后启动本地 Agent
```bash
CANVAS_URL=http://localhost:3000 npx -y @basketikun/canvas-agent
npx -y @basketikun/canvas-agent
```
从 Agent 输出或 `http://127.0.0.1:17371/config` 读取本地地址和 token 后,打开:
@@ -0,0 +1,173 @@
---
title: 本地 Codex 连接画布原理
description: 说明 Canvas Agent、MCP、SSE 和浏览器画布之间如何交互
---
# 本地 Codex 连接画布原理
本地 Codex 连接画布的核心不是让 Codex 直接控制浏览器,也不是模拟鼠标点击或操作 DOM,而是在用户电脑上运行一个 `Canvas Agent` 作为桥接服务。
```txt
Codex / 浏览器侧边栏
|
v
本机 Canvas Agent
|
v
浏览器画布 React 状态
```
真正的画布数据仍然在浏览器里,节点、连线、选区和视口都由前端 React 状态维护。Codex 只能通过 `Canvas Agent` 暴露的工具协议发出“读取画布”或“执行画布操作”的请求。
## 两个使用方向
### 1. 本地 Codex 主动连接画布
这个方向的入口在 Codex。
```txt
本地 Codex
-> infinite-canvas MCP 工具
-> 本机 Canvas Agent
-> 浏览器画布
```
用户在本地 Codex 里发起请求,例如读取画布、创建文本节点、连接节点或触发生成。Codex 调用 `infinite-canvas` MCP 工具,MCP 工具再请求本机 `Canvas Agent` 的 `/api/tools` 接口。
常见工具包括:
- `canvas_get_state`:读取当前画布摘要。
- `canvas_get_selection`:读取当前选区。
- `canvas_apply_ops`:批量执行画布操作。
- `canvas_create_text_node`:创建文本节点。
- `canvas_generate_image` / `canvas_generate_text`:创建生成流程并触发生成。
- `canvas_update_node` / `canvas_connect_nodes`:更新节点或连接节点。
这条链路适合在 Codex 对话里直接让 Codex 操作当前打开的画布。
### 2. 浏览器右侧面板主动打开 Codex
这个方向的入口在浏览器。
```txt
浏览器右侧 Codex 面板
-> 本机 Canvas Agent
-> Codex app-server --stdio
-> infinite-canvas MCP 工具
-> 本机 Canvas Agent
-> 浏览器画布
```
用户在网页右侧面板输入提示词后,浏览器把请求发送到 `Canvas Agent` 的 `/agent/codex/turn` 接口。`Canvas Agent` 会在本机启动或复用 `codex app-server --stdio`,并给这个 Codex 会话注入同一套 `infinite-canvas` MCP 工具。
所以即使入口在浏览器右侧面板,真正改动画布时仍然会回到同一条工具链路:
```txt
Codex -> MCP -> Canvas Agent -> 浏览器画布
```
右侧面板只是把“打开 Codex 会话、发送 prompt、展示流式事件”这件事搬到了网页里。
## 浏览器和 Canvas Agent 如何连接
`Canvas Agent` 默认监听本机地址:
```txt
http://127.0.0.1:17371
```
启动后会生成 `Connect token`,网页连接时必须携带 token。浏览器连接后会建立一条 SSE 长连接:
```txt
GET /events?token=xxx&clientId=xxx
```
连接成功后,`Canvas Agent` 会发送 `hello` 事件,浏览器把状态更新为已连接。
## 画布状态如何同步给 Codex
浏览器会把当前画布快照主动同步给 `Canvas Agent`
```txt
POST /canvas/state?token=xxx&clientId=xxx
```
快照包含:
- 当前画布 ID 和标题;
- 节点列表;
- 连线列表;
- 当前选中的节点 ID
- 当前视口位置和缩放。
`Canvas Agent` 把最近一次快照保存在内存里。Codex 调用 `canvas_get_state` 时,读取的是这份由浏览器同步过来的最新快照,而不是直接读取浏览器 DOM。
## Codex 如何操作画布
以创建文本节点为例,流程如下:
```txt
Codex 调 canvas_create_text_node
-> MCP POST /api/tools
-> Canvas Agent 转成 canvas_apply_ops
-> Canvas Agent 通过 SSE 发送 tool_call
-> 浏览器执行 add_node
-> 浏览器 POST /canvas/result
-> Canvas Agent 返回结果给 Codex
```
`Canvas Agent` 会把一些高级工具转换成统一的画布操作:
```json
{
"ops": [
{
"type": "add_node",
"nodeType": "text",
"metadata": {
"content": "文本内容",
"status": "success"
}
}
]
}
```
浏览器收到 `tool_call` 后,调用前端的 `applyCanvasAgentOps`,在 React 状态中执行 `add_node`、`update_node`、`delete_node`、`connect_nodes`、`set_viewport`、`select_nodes` 等操作。执行完成后再把结果通过 `/canvas/result` 回传给 `Canvas Agent`。
## 工具确认和撤销
为了避免 Codex 直接改动画布,浏览器侧对 `canvas_apply_ops` 默认有二次确认机制。收到写操作后,右侧面板会先展示待执行工具调用,用户确认后才真正应用到画布。
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
如果关闭工具确认,本地 Codex 可以直接执行画布工具调用,但仍然是通过同一套 `Canvas Agent -> SSE -> 浏览器执行` 的链路。
## 安全边界
- `Canvas Agent` 默认只监听 `127.0.0.1`。
- 网页连接必须携带本机生成的 `Connect token`。
- 第一次带正确 token 连接后,`Canvas Agent` 会记录网页 Origin,避免其他来源复用当前本机 Agent。
- Codex 不直接访问浏览器 DOM,也不直接持有画布内部状态。
- 线上网页不保存本机 Codex 登录态、API Key 或本地文件权限。
- 画布写操作最终由浏览器前端执行,因此可以在前端做确认、撤销和权限控制。
## 一句话总结
本地 Codex 连接画布的本质是:
```txt
Codex 调 MCP 工具,Canvas Agent 做本机桥接,浏览器通过 SSE 接收工具调用并在 React 状态中真正修改画布。
```
读状态是:
```txt
浏览器快照 -> Canvas Agent 内存 -> MCP 工具 -> Codex
```
写操作是:
```txt
Codex -> MCP 工具 -> Canvas Agent -> SSE tool_call -> 浏览器 applyOps -> /canvas/result -> Codex
```
+2 -1
View File
@@ -9,6 +9,7 @@
"render",
"docker",
"third-party-prompt-repositories",
"[在线体验](https://canvas.best/)"
"[在线体验](https://canvas.best/)",
"local-codex-canvas"
]
}
+33 -1
View File
@@ -5,9 +5,37 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- Codex App 插件文档:新增插件安装教程,区分 AI 自动安装、手动安装和本仓库开发调试安装;同步优化插件 README 与技能说明
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线)。
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 {{input}} 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互。
- 画布节点插件系统:卸载或禁用插件后,画布上遗留的该类型节点显示「缺少插件」占位且数据保留,重新安装/启用后恢复渲染;需验证刷新后已启用插件自动加载。
- 画布节点插件系统:每个插件为 `plugins/canvas/<name>/` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/<name>.js` 并同步到 `web/public/plugins/`;需验证各插件可独立构建与安装。
- 画布节点插件本地开发:每个插件支持 `npm run dev`(watch 自动构建并同步到 `web/public/plugins/`),配合 `web/.env.local` 的 `VITE_DEV_PLUGINS`(逗号分隔 URL)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效。
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
- 全站 Agent:新增 `site_navigate` 工具,Agent 可直接跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`;导航直接执行不走工具确认,需验证跳转正确且面板不消失。
- 全站 Agent:画布工具(canvas_apply_ops 等)仅在当前处于画布页时可用,不在画布页调用会返回“当前不在画布页,请先用 site_navigate 打开画布”,需验证 Agent 会先导航到画布再执行画布操作。
- 全站 Agent:Codex 会话改为单一站点级连续线程(不再按 canvasId 分),跨页面/跨画布保持同一条对话上下文,历史记录即这条线程;本地 Agent workspace 从按画布分改为固定 `site` workspace`~/.infinite-canvas/codex-workspaces/site`),已装 `@basketikun/canvas-agent` 的用户需更新到新版本才生效。
- 全站 Agent:移除仅前端调用 OpenAI responses 接口的在线画布助手死代码(含 `requestToolResponse`),Agent 统一走 MCP + 本地 Codex 链路。
- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段。
- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。
- 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组。
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
- Codex App 插件:精简插件 README 安装步骤,更新插件网站和作者信息,并将 `open-canvas` 调整为默认打开在线画布、仅按需启动本地前端;需重新安装后确认详情页和两种打开流程正确。
- 本地 Codex 连接画布文档:新增 Canvas Agent、MCP、SSE 与浏览器画布交互原理说明,需确认文档导航和描述是否清晰。
- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。
- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。
- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。
- 画布 AgentCodex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`。
- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话。
- 画布 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 服务。
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。
@@ -17,3 +45,7 @@ description: 当前版本已实现但仍需人工验证的变更项
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
- 环境变量:移除过期的后端 `.env.example` 示例文件。
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
+6 -2
View File
@@ -15,8 +15,8 @@
## 开发与数据
- [本地开发](/docs/backend/local-development)
- [画布数据结构](/docs/backend/canvas-data-structure)
- [本地开发](/docs/development/local-development)
- [画布数据结构](/docs/development/canvas-data-structure)
## 商务合作
@@ -40,3 +40,7 @@
- 当前画布项目和“我的素材”主要保存在浏览器本地,跨设备可自行配置 WebDAV 同步。
- AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
## 原理说明
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
+1 -1
View File
@@ -7,7 +7,7 @@ import { cn } from '@/lib/cn';
const tabs = [
{ title: '项目介绍', href: '/docs/overview/quick-start', prefix: '/docs/overview' },
{ title: '操作手册', href: '/docs/canvas/canvas-node-manual', prefix: '/docs/canvas' },
{ title: '开发文档', href: '/docs/backend/local-development', prefix: '/docs/backend' },
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
+163
View File
@@ -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`(避免被画布缩放拦截)。
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+138
View File
@@ -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 }),
},
];
},
},
],
});
+17
View File
@@ -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"]
}
+23
View File
@@ -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` 字段自动注入/清理。
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+10
View File
@@ -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 };
}
+125
View File
@@ -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 }),
},
];
},
},
],
});
+59
View File
@@ -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%;
}
+17
View File
@@ -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"]
}
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+2
View File
@@ -0,0 +1,2 @@
// three 从 CDN 动态加载,不打进 bundle;声明为宽松类型以便通过 tsc
declare module "https://esm.sh/three@0.180.0";
+311
View File
@@ -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: "" }),
},
]
: []),
];
},
},
],
});
+17
View File
@@ -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"]
}
+39
View File
@@ -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
```
+69
View File
@@ -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
View File
@@ -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"
}
}
}
}
+14
View File
@@ -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"
}
}
+77
View File
@@ -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` 公开契约的镜像;宿主契约变更时同步此处。
+75
View File
@@ -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`);
}
}
+535
View File
@@ -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"
}
}
}
}
+24
View File
@@ -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"
}
}
+18
View File
@@ -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 };
+8
View File
@@ -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";
+16
View File
@@ -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);
}
+38
View File
@@ -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;
}
+37
View File
@@ -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;
+331
View File
@@ -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;
+18
View File
@@ -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"]
}
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -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"
}
}
}
}
+17
View File
@@ -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"
}
}
+161
View File
@@ -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,
},
],
});
+17
View File
@@ -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"]
}
+17
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -0,0 +1,65 @@
{
"name": "canvas-plugin-svg",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canvas-plugin-svg",
"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"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "canvas-plugin-svg",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas SVG 节点插件",
"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"
}
}
+97
View File
@@ -0,0 +1,97 @@
// SVG 节点:编辑与渲染 SVG,透明背景直接融入画布;无自身内容时可取上游文本节点里的 SVG 源码。
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
function SvgContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const stored = ctx.node.metadata?.content as string | undefined;
const upstream = ctx
.getUpstream()
.map((node) => node.metadata?.content)
.find((text): text is string => typeof text === "string" && text.trim().startsWith("<svg"));
const value = stored ?? "";
const svg = value.trim() || upstream || "";
// 无自身内容但有上游 SVG:自动采用上游源码
useEffect(() => {
if (stored === undefined && upstream) ctx.updateMetadata({ content: upstream });
}, [ctx, stored, upstream]);
// 点击节点外部时退出编辑。用 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上
// preventDefault 会抑制 mousedown,capture 又能先于宿主 stopPropagation 触发。
useEffect(() => {
if (!editing) return;
const onDocDown = (e: PointerEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setEditing(false);
};
document.addEventListener("pointerdown", onDocDown, true);
return () => document.removeEventListener("pointerdown", onDocDown, true);
}, [editing]);
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
const toggle = { position: "absolute", right: 8, top: 8, zIndex: 20, width: 32, height: 32, display: "grid", placeItems: "center", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: `${ctx.theme.toolbar.panel}dd`, color: ctx.theme.node.text, cursor: "pointer" } as const;
return (
<div
ref={rootRef}
data-canvas-no-zoom
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", cursor: editing ? "text" : "move" }}
onDoubleClick={(e) => {
e.stopPropagation();
setEditing(true);
}}
>
<button type="button" style={toggle} onMouseDown={stop} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
{editing ? "👁" : "✎"}
</button>
{editing ? (
<textarea
autoFocus
value={value}
placeholder="粘贴 SVG 源码,如 <svg …>…</svg>"
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.stopPropagation();
setEditing(false);
}
}}
onMouseDown={stop}
onPointerDown={stop}
onWheel={stop}
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, padding: 16, boxSizing: "border-box", fontFamily: "monospace", fontSize: 12, outline: "none", border: `1px solid ${ctx.theme.node.stroke}`, color: ctx.theme.node.text }}
/>
) : svg ? (
// pointerEvents:none 让整块可拖动移动,双击/拖拽都命中外层节点
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 12, boxSizing: "border-box", pointerEvents: "none" }} dangerouslySetInnerHTML={{ __html: svg }} />
) : (
<div style={{ height: "100%", width: "100%", display: "flex", alignItems: "center", justifyContent: "center", padding: 16, boxSizing: "border-box", color: ctx.theme.node.placeholder, fontSize: 13, textAlign: "center", pointerEvents: "none" }}>, SVG </div>
)}
</div>
);
}
export default definePlugin({
id: "svg",
name: "SVG 节点",
version: "1.1.0",
description: "透明背景渲染 SVG 矢量图,可接收上游文本节点的 SVG 源码",
nodes: [
{
type: "svg:vector",
title: "SVG",
icon: "🔷",
description: "渲染 SVG 矢量图",
defaultSize: { width: 320, height: 320 },
defaultMetadata: {},
minimapColor: "#14b8a6",
// 背景/边框透明,矢量图直接融入画布
transparentBackground: true,
Content: SvgContent,
},
],
});
+17
View File
@@ -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"]
}
+25
View File
@@ -0,0 +1,25 @@
# 插件模板(TypeScript + SDK)
复制本目录即可开始写一个新的画布节点插件。
## 上手
```bash
cp -r plugins/canvas/template plugins/canvas/my-plugin
cd plugins/canvas/my-plugin
# 改 package.json 的 name;改 src/index.tsx 里的 id / name / type
npm install
npm run dev # watch 构建,产物同步到 web/public/plugins/my-plugin.js
npm run typecheck
```
然后启动画布 `web`,在「节点插件」管理器里启用(自动发现)即可看到你的节点。发布时把 `dist/<name>.js` 托管到任意静态地址,用户填 URL 安装。
> 产物名取**目录名**(`my-plugin/` → `my-plugin.js`),所以复制后记得把目录改成你的插件名。
## 你只需要关心
- `src/index.tsx``Content({ ctx })` 的节点 UI 与逻辑;
- `definePlugin({...})` 里的 `id` / `nodes[].type` / `defaultSize` 等元信息。
类型、JSX、宿主 React、构建都由 `@infinite-canvas/plugin-sdk` 提供,写 TSX 全程有补全。`ctx` 的完整能力见 `plugins/canvas/README.md` 与 SDK 的类型定义。
+3
View File
@@ -0,0 +1,3 @@
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
await buildPlugin(import.meta.url);
+65
View File
@@ -0,0 +1,65 @@
{
"name": "canvas-plugin-template",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "canvas-plugin-template",
"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"
}
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "canvas-plugin-template",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Infinite Canvas 插件起步模板(TypeScript + SDK)",
"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"
}
}
+69
View File
@@ -0,0 +1,69 @@
// 插件模板:复制本目录 → 改 id/name/type → 写你的节点。
// 这是一个演示节点:编辑文本、跟随主题、读取上游节点、用画布指令衍生新节点。
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
function TemplateContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
const content = ctx.node.metadata?.content || "";
// 读取上游相连节点的文本内容(演示 ctx.getUpstream)
const upstreamText = ctx
.getUpstream()
.map((node) => node.metadata?.content)
.filter(Boolean)
.join("\n");
// 用画布指令集衍生一个文本节点并连线(演示 ctx.applyOps)
const spawnBelow = () => {
const id = `template-${ctx.node.id}-${ctx.getNodes().length}`;
ctx.applyOps([
{ type: "add_node", id, nodeType: "text", title: "衍生节点", x: ctx.node.position.x, y: ctx.node.position.y + ctx.node.height + 40, metadata: { content, status: "success" } },
{ type: "connect_nodes", fromNodeId: ctx.node.id, toNodeId: id },
]);
};
const btn = { padding: "4px 10px", borderRadius: 8, border: `1px solid ${ctx.theme.node.stroke}`, background: ctx.theme.toolbar.panel, color: ctx.theme.node.text, cursor: "pointer", fontSize: 12 } as const;
return (
// data-canvas-no-zoom + stopPropagation:交互控件避免触发画布拖拽/缩放
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", gap: 8, padding: 12, boxSizing: "border-box", color: ctx.theme.node.text }}>
<div style={{ display: "flex", gap: 6 }}>
<button type="button" style={btn} onClick={() => setEditing((v) => !v)}>
{editing ? "完成" : "编辑"}
</button>
<button type="button" style={btn} onClick={spawnBelow}>
</button>
</div>
{editing ? (
<textarea autoFocus value={content} placeholder="输入内容…" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ flex: 1, resize: "none", border: "none", outline: "none", background: "transparent", color: ctx.theme.node.text, fontSize: 14, lineHeight: 1.5 }} />
) : (
<div onWheel={(e) => e.stopPropagation()} style={{ flex: 1, overflow: "auto", whiteSpace: "pre-wrap", fontSize: 14, lineHeight: 1.5 }}>
{content || upstreamText || <span style={{ color: ctx.theme.node.placeholder }}>,</span>}
</div>
)}
</div>
);
}
export default definePlugin({
id: "template", // ← 改成你的唯一 id(kebab-case)
name: "模板节点",
version: "1.0.0",
description: "插件起步模板:改我。",
nodes: [
{
type: "template:node", // ← 建议 "<id>:<name>",全局唯一
title: "模板",
icon: "✨",
description: "起步示例节点",
defaultSize: { width: 280, height: 200 },
defaultMetadata: { content: "" },
minimapColor: "#8b5cf6",
// 作为上游输入被消费时输出文本(可连给生成/其它节点);不需要可删
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
Content: TemplateContent,
},
],
});
+17
View File
@@ -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"]
}
@@ -3,8 +3,10 @@
"version": "0.1.0",
"description": "Use Codex to read and operate Infinite Canvas through the local Canvas Agent MCP server.",
"author": {
"name": "Infinite Canvas"
"name": "basketikun",
"url": "https://canvas.best/"
},
"homepage": "https://canvas.best/",
"repository": "https://github.com/basketikun/infinite-canvas",
"license": "AGPL-3.0",
"keywords": ["infinite-canvas", "canvas", "mcp", "agent"],
@@ -14,9 +16,10 @@
"displayName": "Infinite Canvas",
"shortDescription": "Use Codex to operate the current Infinite Canvas board.",
"longDescription": "Connects Codex to the local Canvas Agent MCP server so Codex can inspect the current Infinite Canvas board, create nodes, connect flows, and trigger generation through the web canvas confirmation flow.",
"developerName": "Infinite Canvas",
"developerName": "basketikun",
"category": "Productivity",
"capabilities": ["Canvas", "MCP", "Creative Workflow"],
"websiteURL": "https://canvas.best/",
"defaultPrompt": [
"打开 Infinite Canvas",
"读取当前画布并总结节点结构",
+15 -95
View File
@@ -1,111 +1,31 @@
# Infinite Canvas Codex Plugin
这个插件把 Infinite Canvas 的本地 Canvas Agent MCP 打包给 Codex app 使用,让 Codex 能打开本地画布、读取当前节点、创建内容并触发生成流程
让 Codex 可以打开并操作 Infinite Canvas。
## 安装
### AI 自动安装
把下面这段发给 Codex
```text
请从 https://github.com/basketikun/infinite-canvas.git 安装 Infinite Canvas Codex 插件。
请 clone 仓库到 ~/plugins/infinite-canvas,确认 plugins/infinite-canvas/.codex-plugin/plugin.json 存在,
把 plugins/infinite-canvas 加入 personal marketplace,先运行 codex plugin marketplace add ~
再运行 codex plugin add infinite-canvas@personal。
安装后请校验插件,并告诉我是否需要开启一个新对话来加载新技能和 MCP 工具。
```
### 手动安装
推荐把仓库 clone 到 Codex personal marketplace 默认会引用的位置:
macOS / Linux
```bash
mkdir -p ~/plugins
git clone https://github.com/basketikun/infinite-canvas.git ~/plugins/infinite-canvas
```
确保 `~/.agents/plugins/marketplace.json` 中有 Infinite Canvas 条目,注意 `path` 指向仓库里的插件子目录:
```json
{
"name": "personal",
"interface": {
"displayName": "Personal"
},
"plugins": [
{
"name": "infinite-canvas",
"source": {
"source": "local",
"path": "./plugins/infinite-canvas/plugins/infinite-canvas"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
```
然后注册 personal marketplace 并安装插件:
```bash
codex plugin marketplace add ~
codex plugin add infinite-canvas@personal
```
安装后建议开启一个新的 Codex 对话,让新的 skill 和 MCP 工具完整加载。
### 本仓库开发调试
如果你就在 Infinite Canvas 仓库中调试插件,可以直接添加仓库自带 marketplace。建议使用仓库绝对路径,避免 Codex 从其他工作目录解析失败:
```bash
cd /path/to/infinite-canvas
git clone https://github.com/basketikun/infinite-canvas.git
cd infinite-canvas
codex plugin marketplace add "$(pwd)"
codex plugin add infinite-canvas@infinite-canvas-local
```
## 使用
Windows PowerShell
1. 新建 Codex 线程后说“打开 Infinite Canvas”。
2. 插件会确认当前仓库的本地画布服务是否已运行;端口被占用时会检查进程归属,不会把其他项目的 `3000` 当作 Infinite Canvas。
3. 确认或启动后,插件会直接打开新建画布 URL,并自动尝试连接本地 Agent。
4. 画布打开后,让 Codex 读取或操作当前画布。
```powershell
git clone https://github.com/basketikun/infinite-canvas.git
cd infinite-canvas
codex plugin marketplace add "$PWD"
codex plugin add infinite-canvas@infinite-canvas-local
```
常用提示:
Windows CMD 将 `$PWD` 替换为 `%cd%`
安装后新建一个 Codex 任务,然后输入:
```text
打开 Infinite Canvas
读取当前画布并总结节点结构
根据选中节点创建一组生图提示词
帮我打开并连接到 Infinite Canvas
```
## 工作机制
插件默认通过以下命令启动 MCP,并会在 MCP 启动时自动尝试拉起本地 Agent:
```bash
npx -y @basketikun/canvas-agent mcp
```
## 手动排查
优先本地启动画布:
```bash
cd web
bun install
bun run dev
```
然后启动本地 Agent。端口不是 `3000` 时,把 `CANVAS_URL` 换成真实本地画布地址:
```bash
CANVAS_URL=http://localhost:3000 npx -y @basketikun/canvas-agent
```
手动排查时先从 Agent 输出或 `http://127.0.0.1:17371/config` 读取本地地址和 token,然后直接打开 `<画布网页地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>`。不要通过页面点击来新建画布;`mode=new` 会让网页自动创建具体画布并连接本地 Agent。
@@ -1,36 +1,58 @@
---
name: open-canvas
description: 打开 Infinite Canvas 网页画布并自动连接本地 Canvas Agent。用户要求打开、启动、进入使用 Infinite Canvas 画布时使用。
description: 打开 Infinite Canvas 在线或本地画布并自动连接本地 Canvas Agent。用户要求打开、启动、进入使用 Infinite Canvas 画布时使用。
---
# Open Infinite Canvas
当用户要求打开、启动、进入或使用 Infinite Canvas 时,不要把 URL 交给用户手动复制,不要通过浏览器点击“新建画布”。优先快速拉起本地画布和本地 Canvas Agent,然后直接打开带 `mode``agentUrl``agentToken` 的 URL,让网页自动创建或选择画布并连接 Agent
默认打开在线版。只有用户明确要求使用本地项目时,才启动本地前端
## 默认打开方式
## 在线版
- 新建画布:`<画布网页地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>`
- 最近画布:`<画布网页地址>/canvas?mode=recent&agentUrl=<Local URL>&agentToken=<Connect token>`
- 自己选择:`<画布网页地址>/canvas?mode=choose&agentUrl=<Local URL>&agentToken=<Connect token>`
1. 启动本地 Canvas Agent 并保持运行:
默认打开新建本地画布;只有用户明确要求线上地址、最近画布或自己选择时,才改用对应模式。
```bash
npx -y @basketikun/canvas-agent
```
## 工作流
2. 从启动输出取得 `Local URL``Connect token`
1. 如果当前仓库是 Infinite Canvas 项目,优先使用当前仓库的 `web/` 前端。
2. 先检查本地端口归属:如果 `3000``3001` 等端口已被占用,必须用 `lsof`/`ps` 或服务输出确认监听进程的工作目录属于当前仓库的 `web/`,不能只因为端口存在就当成本地画布。
3. 如果已有当前仓库的 Next dev 服务,复用它并记录真实画布地址,例如 `http://localhost:3001`
4. 如果没有当前仓库的服务,启动本地画布开发服务,默认在 `web/` 下运行 `bun run dev`;若默认端口被其他项目占用,改用空闲端口启动,例如 `bunx next dev --webpack -H 0.0.0.0 -p <空闲端口>`。不要执行构建或测试。
5. 启动本地 Canvas Agent,必须带上第 3/4 步得到的真实画布地址:`CANVAS_URL=<真实画布地址> npx -y @basketikun/canvas-agent`。如果 Agent 已经在运行,则读取 `~/.infinite-canvas/canvas-agent.json``/config` 获取 `Local URL` 和 token。
6. 读取 Agent 输出或配置中的 `Local URL``Connect token`,不要让用户手动复制。
7. 不走本地 Agent 的 `/open` 跳转;直接构造并打开最终 URL`<真实画布地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>`
8. 画布网页会自动新建具体画布、打开本机 Agent 面板并连接本地 Agent;不要用浏览器点击新建画布。
9. 打开后再使用 `canvas_get_state` 检查画布是否已经连接;如果尚未连接,等待片刻再检查,不要改用线上站点,除非用户明确要求。
3. 在 Codex 右侧浏览器打开:
## 用户只安装插件时
```text
https://canvas.best/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
```
- 如果当前工作区不是 Infinite Canvas 源码仓库,优先提示用户先打开或启动 Infinite Canvas 网页,再连接本地 Agent。
- 可以使用线上画布地址或用户给出的本地地址作为 `<画布网页地址>`,但仍要通过本地 Canvas Agent 获取 token 后再打开最终 URL。
- 不要假设用户已经安装本仓库依赖;插件的 MCP 会通过 `npx -y @basketikun/canvas-agent mcp` 使用已发布的 Canvas Agent。
## 本地版
不要要求用户手动填写 URL、token 或复制 JSON。
1. 在 Infinite Canvas 项目中启动前端,并使用 Vite 输出的 `Local` 地址:
```bash
cd web
bun install
bun run dev
```
2. 启动本地 Canvas Agent
```bash
npx -y @basketikun/canvas-agent
```
3. 从启动输出取得 `Local URL``Connect token`,在 Codex 右侧浏览器打开:
```text
<Vite Local 地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
```
## MCP 与连接地址
插件在新的 Codex 任务中加载时会自动启动 `npx -y @basketikun/canvas-agent mcp`。这个 MCP 进程负责提供画布工具,不提供网页连接服务;
上面启动的普通 Canvas Agent 负责提供 `Local URL``Connect token`。两个进程读取同一份本地配置,因此不需要用户手动填写地址或 token。
## 打开模式
用户没有明确指定打开方式时,始终使用 `mode=new` 新建画布。只有用户明确要求时才替换为:
- 最近画布:`mode=recent`
- 自己选择:`mode=choose`
+7
View File
@@ -0,0 +1,7 @@
{
"framework": "vite",
"installCommand": "cd web && bun install",
"buildCommand": "cd web && bun run build",
"outputDirectory": "web/dist",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
+585 -33
View File
File diff suppressed because it is too large Load Diff
+18030
View File
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -5,7 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "tsc --noEmit && vite build",
"build": "vite build",
"typecheck": "tsc --noEmit",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
"format:check": "prettier --check ."
@@ -13,6 +14,7 @@
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9",
"@uiw/react-codemirror": "^4.25.9",
@@ -34,6 +36,7 @@
"react-router": "^7.12.0",
"react-router-dom": "^7.12.0",
"shadcn": "^4.7.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
@@ -41,11 +44,11 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@vitejs/plugin-react": "^5.2.0",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react": "^5.2.0",
"prettier": "^3.8.3",
"typescript": "^5",
"vite": "^7.3.0"
+87
View File
@@ -0,0 +1,87 @@
import { useState, type PointerEvent as ReactPointerEvent } from "react";
import { Bot, PanelRightClose } from "lucide-react";
import { Button, Switch, Tooltip } from "antd";
import { motion } from "motion/react";
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { CANVAS_AGENT_PANEL_MOTION_MS, useAgentStore } from "@/stores/use-agent-store";
import { useThemeStore } from "@/stores/use-theme-store";
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
export function AgentPanel() {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const width = useAgentStore((state) => state.width);
const [resizing, setResizing] = useState(false);
const panelMounted = useAgentStore((state) => state.panelMounted);
const panelOpen = useAgentStore((state) => state.panelOpen);
const panelClosing = useAgentStore((state) => state.panelClosing);
const confirmTools = useAgentStore((state) => state.confirmTools);
const setAgentState = useAgentStore((state) => state.setAgentState);
const closePanel = useAgentStore((state) => state.closePanel);
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = Math.min(760, Math.max(360, startWidth + startX - moveEvent.clientX));
setAgentState({ width: nextWidth });
};
const onUp = () => {
localStorage.setItem("canvas-agent-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
if (!panelMounted) return null;
return (
<motion.div
className="relative z-[70] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: panelClosing ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整右侧面板宽度" />
<header className="flex h-14 shrink-0 items-center justify-between border-b px-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-8 place-items-center rounded-lg">
<Bot className="size-4" />
</span>
<div className="min-w-0">
<div className="text-base font-semibold leading-5">Agent</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}></div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
</label>
<Tooltip title="收起对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
</Tooltip>
</div>
</header>
<CanvasLocalAgentPanel embedded />
</motion.aside>
</motion.div>
);
}
@@ -16,7 +16,7 @@ type Props = {
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
return (
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<Modal title="选择资产" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
<MyAssetsTab onInsert={onInsert} />
</Modal>
);
@@ -90,7 +90,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
className="w-56"
size="small"
prefix={<Search className="size-3.5 text-stone-400" />}
placeholder="搜索素材"
placeholder="搜索资产"
value={keyword}
allowClear
onChange={(e) => {
@@ -122,7 +122,7 @@ function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) =>
))}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有资产" className="py-12" />
)}
{filtered.length > PAGE_SIZE && (
@@ -1,12 +1,13 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
import { Streamdown } from "streamdown";
import { isPlainEnterKey } from "@/lib/keyboard-event";
import { canvasThemes } from "@/lib/canvas-theme";
import type { LocalUser } from "@/stores/use-user-store";
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
export type CanvasAgentMode = "online" | "local";
export type CanvasAgentChatMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
@@ -15,6 +16,8 @@ export type CanvasAgentChatMessage = {
meta?: string;
detail?: unknown;
attachments?: CanvasAgentChatAttachment[];
/** Present while the message is actively streaming; cleared on completion. */
streamId?: string;
};
const WORKING_TEXT = "working...";
@@ -46,7 +49,11 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}>
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
{isUser ? (
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
) : (
<Streamdown animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
)}
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null}
</div>
@@ -152,6 +159,7 @@ export function AgentChatComposer({
theme,
onPromptChange,
onSubmit,
onStop,
onAddFiles,
onRemoveAttachment,
left,
@@ -164,6 +172,7 @@ export function AgentChatComposer({
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onPromptChange: (value: string) => void;
onSubmit: () => void;
onStop?: () => void;
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
onRemoveAttachment?: (id: string) => void;
left?: ReactNode;
@@ -198,7 +207,7 @@ export function AgentChatComposer({
void onAddFiles(images);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey) return;
if (!isPlainEnterKey(event)) return;
event.preventDefault();
void onSubmit();
}}
@@ -221,25 +230,19 @@ export function AgentChatComposer({
) : null}
{left}
</div>
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
<div className="flex shrink-0 items-center gap-1.5">
{sending && onStop ? (
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
) : (
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
)}
</div>
</div>
</div>
</div>
);
}
export function AgentModeSwitch({ value, theme, onChange }: { value: CanvasAgentMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: CanvasAgentMode) => void }) {
return (
<div className="inline-flex shrink-0 rounded-lg border p-0.5 text-xs" style={{ borderColor: theme.node.stroke }}>
{(["online", "local"] as const).map((item) => (
<button key={item} type="button" className="rounded-md px-2 py-1 transition" style={{ background: value === item ? theme.node.fill : "transparent", color: value === item ? theme.node.text : theme.node.muted }} onClick={() => onChange(item)}>
{item === "online" ? "网站" : "本机"}
</button>
))}
</div>
);
}
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
return (
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
File diff suppressed because it is too large Load Diff
@@ -116,7 +116,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
<div className="mb-2 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline gap-2">
<div className="shrink-0 text-xs font-semibold"></div>
<div className="truncate text-[11px] opacity-55">@ </div>
<div className="truncate text-[11px] opacity-55">@ </div>
</div>
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
</div>
@@ -3,8 +3,7 @@ import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings
import { Button, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
@@ -28,8 +27,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? count : 1 });
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
@@ -126,10 +123,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
</>
) : (
<>
<span className="inline-flex items-center gap-1">
<CreditSymbol />
{credits.toLocaleString()}
</span>
<Play className="size-4" />
<span></span>
</>
@@ -151,9 +144,16 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
@@ -25,8 +25,8 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="复制" onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label="删除" onClick={onDelete} danger />
</div>
);
}
@@ -1,26 +1,27 @@
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { useSearchParams } from "react-router-dom";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button, Input, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, Square, Terminal, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { randomId } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/canvas/use-canvas-agent-store";
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_CONNECT_STEPS = [
{ title: "安装 Codex 插件", text: "在 Codex app 安装 Infinite Canvas 插件后,首次使用插件会自动启动本地 Agent。" },
{ title: "打开画布连接", text: "回到这里点击连接,网页会自动读取本机 Agent 配置。" },
{ title: "手动启动备用", text: "如果自动发现失败,再运行下面命令。", command: "npx -y @basketikun/canvas-agent" },
{ title: "方式一:在 Codex 中使用插件", text: "在 Codex app 安装 Infinite Canvas 插件后,通过插件启动画布,插件会自动启动本地 Agent 并带上连接信息。" },
{ title: "方式二:直接运行 Agent", text: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", command: "npx -y @basketikun/canvas-agent" },
];
const AGENT_PLUGIN_REMOVE_COMMAND = "codex plugin remove infinite-canvas";
const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
type AgentEventPayload = {
agent?: string;
@@ -34,45 +35,43 @@ type AgentEventPayload = {
type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string };
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, headless, autoConnect, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; headless?: boolean; autoConnect?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) {
export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const { message, modal } = App.useApp();
const [searchParams] = useSearchParams();
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore();
const [resizing, setResizing] = useState(false);
const navigate = useNavigate();
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, canvasContext, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useAgentStore();
const listRef = useRef<HTMLDivElement>(null);
const snapshotRef = useRef(snapshot);
const canvasContextRef = useRef(canvasContext);
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
const onApplyOpsRef = useRef(onApplyOps);
const autoConnectRef = useRef(false);
const connectedRef = useRef(false);
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID());
const clientIdRef = useRef(randomId());
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const loadThreads = useCallback(async () => {
const projectId = snapshotRef.current.projectId;
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
if (!connectedRef.current && !useAgentStore.getState().connected) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`);
const current = useCanvasAgentStore.getState();
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
const nextThreadId = data.workspace?.activeThreadId || "";
setAgentState({
threads: data.data || [],
workspacePath: data.workspace?.workspacePath || current.workspacePath,
activeThreadId: data.workspace?.activeThreadId || current.activeThreadId,
workspacePath: data.workspace?.workspacePath || "",
activeThreadId: nextThreadId,
messages: [],
});
const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId;
if (nextThreadId && !current.messages.length) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
if (nextThreadId) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
}
} catch (error) {
@@ -83,17 +82,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}, [endpoint, setAgentState, token]);
useEffect(() => {
snapshotRef.current = snapshot;
}, [snapshot]);
canvasContextRef.current = canvasContext;
}, [canvasContext]);
useEffect(() => {
confirmToolsRef.current = confirmTools;
}, [confirmTools]);
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
useEffect(() => {
onApplyOpsRef.current = onApplyOps;
}, [onApplyOps]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages, pendingTool, waiting]);
@@ -108,9 +104,9 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
source.addEventListener("hello", () => {
errorLoggedRef.current = false;
connectedRef.current = true;
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
setAgentState({ connected: true, activity: "已连接", connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, snapshotRef.current);
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
});
source.addEventListener("tool_call", (event) => {
const data = parseEventData<AgentPendingToolCall>(event);
@@ -136,14 +132,15 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
});
source.onerror = () => {
const wasConnected = connectedRef.current;
const silent = useAgentStore.getState().silentConnect && !wasConnected;
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token";
if (!errorLoggedRef.current || wasConnected) {
addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text });
if (!headless) message.error(text);
if (!headless && !silent) message.error(text);
}
errorLoggedRef.current = true;
connectedRef.current = false;
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: text });
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: silent ? "" : text, silentConnect: false });
if (!wasConnected) {
source.close();
setAgentState({ enabled: false });
@@ -152,19 +149,18 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
return () => {
source.close();
connectedRef.current = false;
setAgentState({ connected: false });
};
}, [enabled, endpoint, loadThreads, message, setAgentState, token]);
useEffect(() => {
if (connected) void loadThreads();
}, [connected, loadThreads, snapshot.projectId]);
}, [connected, loadThreads]);
useEffect(() => {
if (!connected) return;
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, snapshot), 300);
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300);
return () => clearTimeout(timer);
}, [connected, endpoint, snapshot, token]);
}, [canvasContext?.snapshot, connected, endpoint, token]);
const sendPrompt = async () => {
const text = prompt.trim();
@@ -179,7 +175,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
try {
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
const data = (await res.json()) as { threadId?: string };
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
@@ -198,10 +194,22 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}
};
const stopTurn = async () => {
if (!connected || (!sending && !waiting)) return;
setAgentState({ activity: "停止中" });
try {
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" } });
setAgentState({ activity: "已停止", sending: false, waiting: false });
addEventLog("用户停止", {});
} catch {
setAgentState({ activity: "就绪", sending: false, waiting: false });
}
};
const addAttachments = async (files: FileList | File[] | null) => {
if (!files) return;
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
const prev = useCanvasAgentStore.getState().attachments;
const prev = useAgentStore.getState().attachments;
try {
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
@@ -248,16 +256,46 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
};
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
if (isSiteTool(payload.name)) {
try {
setAgentState({ activity: SITE_TOOL_LABELS[payload.name], waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = await runSiteTool(payload.name, payload.input || {}, navigate);
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: siteToolSummary(payload.name, result), detail: { requestId: payload.requestId, name: payload.name, input: payload.input, result } });
} catch (error) {
const message = error instanceof Error ? error.message : "工具执行失败";
setAgentState({ activity: "工具失败", waiting: false });
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
}
return;
}
try {
const input: { ops?: CanvasAgentOp[] } = payload.input || {};
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布", waiting: true });
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {};
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : payload.name === "site_navigate" ? "跳转页面" : "读取画布", waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = payload.name === "canvas_apply_ops" ? onApplyOpsRef.current(input.ops || []) : snapshotRef.current;
let result: unknown;
if (payload.name === "site_navigate") {
const path = input.path || "/";
navigate(path);
result = { ok: true, path };
} else if (payload.name === "canvas_apply_ops") {
const context = canvasContextRef.current;
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
result = context.applyOps(input.ops || []);
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
} else {
const snapshot = canvasContextRef.current?.snapshot;
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
result = snapshot;
}
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
if (payload.name === "canvas_apply_ops") void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
} catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败";
setAgentState({ activity: "工具失败", waiting: false });
@@ -283,15 +321,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
await runToolCall(endpoint, token, tool);
};
const undoLastTool = () => {
const restored = onUndoOps();
if (!restored) return;
setAgentState({ activity: "已撤销" });
addMessage({ role: "tool", title: "已撤销", text: "上一次工具操作", detail: restored });
if (connected) void postState(endpoint, token, clientIdRef.current, restored);
};
const toggleAgentConnection = async () => {
const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => {
if (enabled) {
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
return;
@@ -303,14 +333,18 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
const nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
if (!nextEndpoint) {
const text = "请填写本地 Agent 地址";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
if (!silent) {
setAgentState({ connectError: text });
if (!headless) message.warning(text);
}
return;
}
if (!nextToken) {
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
if (!silent) {
setAgentState({ connectError: text });
if (!headless) message.warning(text);
}
return;
}
try {
@@ -318,12 +352,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
} catch {
const text = "本地 Agent 地址格式不正确";
setAgentState({ connectError: text });
if (!headless) message.warning(text);
if (!silent) {
setAgentState({ connectError: text });
if (!headless) message.warning(text);
}
return;
}
errorLoggedRef.current = false;
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: "连接中", connectError: "", activeTab: "setup" });
};
useEffect(() => {
@@ -333,7 +369,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
useEffect(() => {
if (!autoConnect || autoConnectRef.current || enabled || connected) return;
autoConnectRef.current = true;
void toggleAgentConnection();
void toggleAgentConnection({ silent: true });
}, [autoConnect, connected, enabled]);
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
@@ -352,11 +388,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}
const startNewThread = async () => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId) return;
if (!connected) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" });
await loadThreads();
} catch (error) {
@@ -368,11 +403,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
};
const resumeThread = async (threadId: string) => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId || !threadId) return;
if (!connected || !threadId) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" });
await loadThreads();
} catch (error) {
@@ -384,12 +418,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
};
const deleteThread = async (threadId: string) => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId || !threadId) return;
if (!connected || !threadId) return;
setAgentState({ loadingThreads: true });
try {
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const current = useCanvasAgentStore.getState();
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
const current = useAgentStore.getState();
setAgentState({
threads: current.threads.filter((thread) => thread.id !== threadId),
activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId,
@@ -416,31 +449,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
});
};
const startResize = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = clamp(startWidth + startX - moveEvent.clientX, 360, 760);
setAgentState({ width: nextWidth });
};
const onUp = () => {
localStorage.setItem("canvas-agent-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const addMessage = (item: Omit<AgentChatItem, "id">) => {
const text = normalizeText(item.text);
if (!text && !item.attachments?.length) return;
const next = { ...item, id: `${Date.now()}-${Math.random()}`, text };
const currentMessages = useCanvasAgentStore.getState().messages;
const currentMessages = useAgentStore.getState().messages;
if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) {
@@ -452,7 +465,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) {
const merged = mergeAgentText(last.text, next.text);
if (merged === last.text) return;
setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
setAgentState({ messages: [...useAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
return;
}
pushMessage(next);
@@ -493,8 +506,8 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}}
right={
<>
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
<Button size="small" type="text" disabled={!connected || loadingThreads} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
</Button>
</>
}
@@ -549,10 +562,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
attachments={attachments.map(agentAttachmentToChatAttachment)}
disabled={!connected}
sending={sending || waiting}
placeholder="询问 Codex,或让它操作画布"
placeholder="询问 Codex,或让它操作网站/画布"
theme={theme}
onPromptChange={(prompt) => setAgentState({ prompt })}
onSubmit={sendPrompt}
onStop={stopTurn}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
@@ -563,28 +577,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
);
if (headless) return null;
if (embedded) return content;
return (
<motion.div
className="relative z-[70] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: collapsed ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
{content}
</motion.aside>
</motion.div>
);
return embedded ? content : null;
}
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
@@ -637,31 +630,54 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
copyToClipboard(command);
message.success("命令已复制");
};
const codexPluginReminder = (
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<div className="font-medium" style={{ color: theme.node.text }}>Codex </div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div>
<div className="mt-2 grid gap-1.5">
{[
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND],
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
].map(([label, command]) => (
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>{label}</span>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
))}
</div>
</div>
);
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div>
<div className="text-base font-semibold leading-6"> Agent</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
Codex Agent
使
</div>
</div>
<div className="space-y-2">
{AGENT_CONNECT_STEPS.map((step) => {
{AGENT_CONNECT_STEPS.map((step, index) => {
const command = "command" in step ? step.command : "";
return (
<div key={step.title} className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
) : null}
</div>
<Fragment key={step.title}>
<div className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
) : null}
</div>
{index === 0 ? codexPluginReminder : null}
</Fragment>
);
})}
</div>
@@ -771,9 +787,9 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
);
}
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot) {
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot) });
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }) });
} catch {}
}
@@ -902,9 +918,22 @@ function toolName(name: string) {
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
if (name === "site_navigate") return "网站跳转";
if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
return name;
}
function siteToolSummary(name: string, result: unknown) {
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
if (name === "canvas_list_projects") return `${numberField(data, "total")} 个画布`;
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
if (name === "assets_list") return `${numberField(data, "total")} 个资产`;
if (name === "assets_add") return "已加入我的资产";
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
return "已完成";
}
function isReadTool(name: string) {
return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot";
}
@@ -1017,7 +1046,7 @@ function formatThreadTime(value?: number) {
}
function createId() {
return typeof crypto === "undefined" ? `${Date.now()}-${Math.random()}` : crypto.randomUUID();
return randomId();
}
function clamp(value: number, min: number, max: number) {
@@ -1,8 +1,9 @@
import { useCallback, useMemo, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import { type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -113,7 +114,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
const color = getNodeDefinition(node.type)?.minimapColor || theme.node.muted;
return (
<div
key={node.id}
@@ -7,6 +7,7 @@ import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import type { CanvasNodeToolbarItem } from "@/types/canvas-plugin";
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
@@ -35,6 +36,7 @@ type CanvasNodeHoverToolbarProps = {
onRetry: (node: CanvasNodeData) => void;
onToggleFreeResize: (node: CanvasNodeData) => void;
onDelete: (node: CanvasNodeData) => void;
extraTools?: CanvasNodeToolbarItem[];
};
type ToolbarTool = {
@@ -72,6 +74,7 @@ export function CanvasNodeHoverToolbar({
onRetry,
onToggleFreeResize,
onDelete,
extraTools = [],
}: CanvasNodeHoverToolbarProps) {
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
@@ -137,7 +140,7 @@ export function CanvasNodeHoverToolbar({
];
const nodeToolbarTools: ToolbarTool[] = [
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的资产", label: "存资产", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
@@ -150,7 +153,7 @@ export function CanvasNodeHoverToolbar({
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools, ...extraTools];
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
const closeImageToolSettings = () => {
@@ -218,7 +221,6 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
return JSON.stringify(
node,
(key, value) => {
if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
@@ -254,7 +256,8 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "生成配置"} />
<InfoRow label="名称" value={node.title || "未命名节点"} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : node.type === CanvasNodeType.Group ? "组" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
@@ -3,8 +3,7 @@ import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
@@ -26,19 +25,19 @@ type CanvasNodePromptPanelProps = {
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const mode = modeOverride ?? defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? config.count : 1 });
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
@@ -119,13 +118,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<span className="text-xs font-medium"></span>
</>
) : (
<>
<span className="inline-flex items-center gap-1 text-xs font-medium tabular-nums">
<CreditSymbol />
{credits.toLocaleString()}
</span>
<ArrowUp className="size-4" />
</>
<ArrowUp className="size-4" />
)}
</span>
</Button>
@@ -140,9 +133,16 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
? defaultModel
: fallbackModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
@@ -1,24 +1,32 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2 } from "lucide-react";
import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
export type CanvasImageSplitParams = ImageSplitParams;
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2, horizontalLines: [0.5], verticalLines: [0.5] };
const maxGridSize = 12;
type ActiveLine = { axis: "horizontal" | "vertical"; index: number } | null;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const total = params.rows * params.columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
const [active, setActive] = useState<ActiveLine>(null);
const previewRef = useRef<HTMLDivElement>(null);
const horizontalLines = params.horizontalLines || [];
const verticalLines = params.verticalLines || [];
const rows = horizontalLines.length + 1;
const columns = verticalLines.length + 1;
const total = rows * columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / columns)), height: Math.max(1, Math.floor(image.height / rows)) } : null;
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setActive(null);
setImage(null);
}, [dataUrl, open]);
@@ -27,9 +35,53 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
const update = (key: "rows" | "columns", value: string | number | null) => {
const count = clampGrid(value ?? params[key]);
setActive(null);
setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) }));
};
const addLine = (axis: "horizontal" | "vertical") => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b);
return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns };
});
};
const deleteLine = () => {
if (!active) return;
setParams((current) => {
const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = (current[key] || []).filter((_, index) => index !== active.index);
return { ...current, [key]: lines, rows: active.axis === "horizontal" ? lines.length + 1 : current.rows, columns: active.axis === "vertical" ? lines.length + 1 : current.columns };
});
setActive(null);
};
const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => {
event.preventDefault();
setActive({ axis, index });
const box = previewRef.current?.getBoundingClientRect();
if (!box) return;
const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width);
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || [])];
lines[index] = clampLine(value, lines[index - 1] ?? 0, lines[index + 1] ?? 1);
return { ...current, [key]: lines };
});
};
const resetLines = () => {
setActive(null);
setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) }));
};
const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns };
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
@@ -41,9 +93,9 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid rows={params.rows} columns={params.columns} />
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
@@ -52,19 +104,25 @@ export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { d
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>线</Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>线</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>线</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>线</Button>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="opacity-60"></span>
<span className="font-semibold">{total} </span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span>
<span className="opacity-60"></span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}>
</Button>
</div>
@@ -83,19 +141,45 @@ function NumberField({ label, value, onChange }: { label: string; value: number;
);
}
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
function SplitGrid({ horizontalLines, verticalLines, active, onPointerDown }: { horizontalLines: number[]; verticalLines: number[]; active: ActiveLine; onPointerDown: (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => void }) {
return (
<div className="pointer-events-none absolute inset-0">
{Array.from({ length: columns - 1 }).map((_, index) => (
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
{verticalLines.map((line, index) => (
<div key={`column-${index}`} className="pointer-events-auto absolute inset-y-0 -ml-2 w-4 cursor-ew-resize" style={{ left: `${line * 100}%` }} onPointerDown={(event) => onPointerDown("vertical", index, event)}>
<div className={`absolute left-1/2 top-0 h-full border-l shadow-[0_0_0_1px_rgba(0,0,0,.35)] ${active?.axis === "vertical" && active.index === index ? "border-amber-300" : "border-white/90"}`} />
</div>
))}
{Array.from({ length: rows - 1 }).map((_, index) => (
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
{horizontalLines.map((line, index) => (
<div key={`row-${index}`} className="pointer-events-auto absolute inset-x-0 -mt-2 h-4 cursor-ns-resize" style={{ top: `${line * 100}%` }} onPointerDown={(event) => onPointerDown("horizontal", index, event)}>
<div className={`absolute left-0 top-1/2 w-full border-t shadow-[0_0_0_1px_rgba(0,0,0,.35)] ${active?.axis === "horizontal" && active.index === index ? "border-amber-300" : "border-white/90"}`} />
</div>
))}
</div>
);
}
function buildGridLines(count: number) {
return Array.from({ length: Math.max(1, count) - 1 }, (_, index) => (index + 1) / count);
}
function findLineSpot(lines: number[]) {
const cuts = [0, ...lines, 1].sort((a, b) => a - b);
let spot = 0.5;
let max = 0;
for (let index = 0; index < cuts.length - 1; index += 1) {
const gap = cuts[index + 1] - cuts[index];
if (gap > max) {
max = gap;
spot = cuts[index] + gap / 2;
}
}
return spot;
}
function clampLine(value: number, min: number, max: number) {
return Math.min(max - 0.01, Math.max(min + 0.01, value));
}
function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
+146 -18
View File
@@ -1,12 +1,15 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
import { ChevronRight, Group, Image as ImageIcon, Music2, Puzzle, RefreshCw, Star, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
@@ -25,20 +28,26 @@ type CanvasNodeProps = {
showImageInfo: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[];
pluginHost?: CanvasPluginHost;
registryVersion?: number;
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
groupChildCount?: number;
isGroupDropTarget?: boolean;
batchExpanded?: boolean;
batchClosing?: boolean;
batchOpening?: boolean;
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onContentChange: (nodeId: string, content: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
@@ -58,6 +67,7 @@ type NodeContentRendererProps = {
batchOpening: boolean;
batchRecovering: boolean;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
pluginContext?: CanvasNodeContext | null;
onContentChange: (nodeId: string, content: string) => void;
onStopEditing: () => void;
mentionReferences: CanvasResourceReference[];
@@ -65,6 +75,7 @@ type NodeContentRendererProps = {
onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
groupChildCount: number;
};
export const CanvasNode = React.memo(function CanvasNode({
@@ -80,20 +91,25 @@ export const CanvasNode = React.memo(function CanvasNode({
showImageInfo,
resourceLabel,
mentionReferences = [],
pluginHost,
renderPanel,
renderNodeContent,
batchCount = 0,
groupChildCount = 0,
isGroupDropTarget = false,
batchExpanded = false,
batchClosing = false,
batchOpening = false,
batchRecovering = false,
batchMotion,
onMouseDown,
onSelectCapture,
onHoverStart,
onHoverEnd,
onConnectStart,
onResize,
onContentChange,
onTitleChange,
onToggleBatch,
onSetBatchPrimary,
onRetry,
@@ -103,15 +119,28 @@ export const CanvasNode = React.memo(function CanvasNode({
}: CanvasNodeProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
// 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
// forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
// 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
const transparentBg = Boolean(definition?.transparentBackground);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const resizeRef = useRef({
isResizing: false,
corner: "bottom-right" as ResizeCorner,
@@ -125,6 +154,34 @@ export const CanvasNode = React.memo(function CanvasNode({
ratio: 1,
});
useEffect(() => {
setTitleDraft(data.title || "");
}, [data.title]);
useEffect(() => {
if (!isEditingTitle) return;
titleInputRef.current?.focus();
titleInputRef.current?.select();
}, [isEditingTitle]);
const finishTitleEditing = useCallback(() => {
const title = titleDraft.trim() || data.title || "未命名节点";
setTitleDraft(title);
setIsEditingTitle(false);
if (title !== data.title) onTitleChange(data.id, title);
}, [data.id, data.title, onTitleChange, titleDraft]);
useEffect(() => {
if (!isEditingTitle) return;
const handleOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && titleInputRef.current?.contains(target)) return;
finishTitleEditing();
};
window.addEventListener("pointerdown", handleOutsidePointerDown, true);
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
}, [finishTitleEditing, isEditingTitle]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
@@ -220,7 +277,7 @@ export const CanvasNode = React.memo(function CanvasNode({
startTop: data.position.y,
startWidth: data.width,
startHeight: data.height,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video,
keepRatio: (data.type === CanvasNodeType.Image && !data.metadata?.freeResize) || data.type === CanvasNodeType.Video || Boolean(definition?.keepAspectRatio?.(data)),
ratio: (data.metadata?.naturalWidth || data.width) / (data.metadata?.naturalHeight || data.height || 1),
};
window.addEventListener("mousemove", handleResizeMove);
@@ -237,7 +294,7 @@ export const CanvasNode = React.memo(function CanvasNode({
return (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : "z-10"}`}
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isGroup ? "z-[5]" : isSelected ? "z-50" : "z-10"}`}
style={{
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
width: data.width,
@@ -253,14 +310,50 @@ export const CanvasNode = React.memo(function CanvasNode({
setHovered(false);
onHoverEnd(data.id);
}}
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{isEditingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
maxLength={64}
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
style={{ borderColor: theme.node.muted, color: theme.node.text }}
onChange={(event) => setTitleDraft(event.target.value)}
onBlur={finishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") finishTitleEditing();
if (event.key === "Escape") {
setTitleDraft(data.title || "");
setIsEditingTitle(false);
}
}}
/>
) : (
<button
type="button"
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
style={{ color: theme.node.text }}
title="双击修改节点名称"
onDoubleClick={(event) => {
event.stopPropagation();
setIsEditingTitle(true);
}}
>
{data.title || "未命名节点"}
</button>
)}
</div>
<div
className="relative h-full w-full overflow-visible rounded-3xl border-2"
style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke,
borderStyle: isGroup ? "dashed" : "solid",
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -269,6 +362,10 @@ export const CanvasNode = React.memo(function CanvasNode({
onToggleBatch?.(data.id);
return;
}
if (definition?.onDoubleClick && pluginContext) {
if (definition.onDoubleClick(pluginContext)) event.stopPropagation();
return;
}
if (data.type === CanvasNodeType.Image && hasImageContent) {
event.stopPropagation();
onViewImage?.(data);
@@ -283,7 +380,8 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
pointerEvents: contentInteractive ? undefined : "none",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -303,6 +401,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchOpening={batchOpening}
batchRecovering={batchRecovering}
renderNodeContent={renderNodeContent}
pluginContext={pluginContext}
mentionReferences={mentionReferences}
onContentChange={onContentChange}
onStopEditing={() => setIsEditingContent(false)}
@@ -310,13 +409,14 @@ export const CanvasNode = React.memo(function CanvasNode({
onGenerateImage={onGenerateImage}
onToggleBatch={() => onToggleBatch?.(data.id)}
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
groupChildCount={groupChildCount}
/>
</div>
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
{!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
@@ -324,10 +424,10 @@ export const CanvasNode = React.memo(function CanvasNode({
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</div>
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
);
});
@@ -338,8 +438,16 @@ function NodeContent(props: NodeContentRendererProps) {
if (props.node.metadata?.status === "loading") return <LoadingContent theme={props.theme} />;
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
const Renderer = nodeContentRenderers[props.node.type];
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
const Renderer = nodeContentRenderers[props.node.type as CanvasNodeType];
if (Renderer) return <Renderer {...props} />;
// 插件节点:有注册渲染器则渲染,否则展示缺少插件占位
const definition = getNodeDefinition(props.node.type);
if (definition?.Content && props.pluginContext) {
const PluginContent = definition.Content;
return <PluginContent ctx={props.pluginContext} />;
}
return <MissingPluginContent theme={props.theme} type={props.node.type} />;
}
const nodeContentRenderers = {
@@ -348,8 +456,26 @@ const nodeContentRenderers = {
[CanvasNodeType.Config]: EmptyImageContent,
[CanvasNodeType.Video]: VideoNodeContent,
[CanvasNodeType.Audio]: AudioNodeContent,
[CanvasNodeType.Group]: GroupNodeContent,
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) {
return (
<div className="pointer-events-none flex h-full w-full flex-col p-4">
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: theme.node.text }}>
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
<Group className="size-4" />
</span>
<span></span>
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
{groupChildCount}
</span>
</div>
<div className="mt-3 flex-1 rounded-2xl border border-dashed" style={{ borderColor: theme.node.stroke, background: `${theme.node.fill}55` }} />
</div>
);
}
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
@@ -380,10 +506,12 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
);
}
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
function MissingPluginContent({ theme, type }: Pick<NodeContentRendererProps, "theme"> & { type: string }) {
return (
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
<div className="flex h-full w-full flex-col items-center justify-center gap-2 px-4 text-center" style={{ color: theme.node.placeholder }}>
<Puzzle className="size-7 opacity-40" />
<span className="text-sm"></span>
<span className="text-[11px] opacity-70"> {type} </span>
</div>
);
}
@@ -521,7 +649,7 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
<Music2 className="size-4 shrink-0" />
<span className="truncate">{node.title || "音频"}</span>
<span className="truncate"></span>
</div>
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
</div>

Some files were not shown because too many files have changed in this diff Show More