mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 23:28:35 +08:00
Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49851f2b5f | |||
| c81bb8b651 | |||
| b7d0fe10bc | |||
| d91e67a5ad | |||
| d566d5cc46 | |||
| 8fb8d0b224 | |||
| 5e1fd7a825 | |||
| 062e4569aa | |||
| b5dc9c8a87 | |||
| 75c22c2f9f | |||
| bdca6b0a5c | |||
| d4130bbb79 | |||
| 0e1edd1484 | |||
| 5107be0acf | |||
| 219b3e84db | |||
| a3411cf9d9 | |||
| 430d1bd956 | |||
| cf0561d12a | |||
| e6dcd7a832 | |||
| 187c499ef9 | |||
| cf1ea2646f | |||
| 57db0fe752 | |||
| c5f53454aa | |||
| ca6efdf0c3 | |||
| a2586ffe34 | |||
| 5a27aae99e | |||
| 66961ce10f | |||
| 0c4288b832 | |||
| 093260f7b2 | |||
| 805573b980 | |||
| e6829333c5 | |||
| 2959b5d340 | |||
| 2f9776bee3 | |||
| 4beadc6161 | |||
| 13c508bc34 | |||
| 5f627d5baf | |||
| 3f179a607a | |||
| 2d2c7e3d5e | |||
| 3f623ea70c | |||
| 7ac2ab351d | |||
| b6e75c225e | |||
| 75aafc115f | |||
| 8d3f244524 | |||
| 88510223eb | |||
| eef4d96787 | |||
| f0db29d8e3 | |||
| 3963c6eebf | |||
| c57f7d61a7 |
@@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: web
|
||||
run: bun install --frozen-lockfile
|
||||
run: bun install
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Publish Official Plugins
|
||||
|
||||
# 构建官方插件并把产物(bundle + 清单)强推到孤儿分支 plugins-dist。
|
||||
# 与 github-pages.yml 一致,在打版本 tag 时触发 —— 发版时 web 与官方插件一起发布。
|
||||
# 前端经 jsDelivr 从该分支远程拉取;构建产物不进 main、不进 git。
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: publish-plugins
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install build dependencies
|
||||
working-directory: plugins/canvas/registry
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build official plugins
|
||||
working-directory: plugins/canvas/registry
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to plugins-dist branch
|
||||
working-directory: plugins/canvas/registry/dist
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git init -q
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -q -b plugins-dist
|
||||
git add -A
|
||||
git commit -q -m "publish official plugins @ ${GITHUB_SHA::7}"
|
||||
git push -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" plugins-dist
|
||||
@@ -11,4 +11,7 @@ data/
|
||||
web/dist
|
||||
|
||||
**/dist
|
||||
web/public/plugins
|
||||
plugins/canvas/*/dist
|
||||
plugins/canvas/*/node_modules
|
||||
web/public/plugins
|
||||
@@ -51,6 +51,8 @@
|
||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
||||
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
|
||||
- 画布内的操作按钮(如面板里的「添加」「导出」「选择」等)默认用扁平无底色样式:透明背景、仅 `hover:bg-black/5 dark:hover:bg-white/10` 轻微反馈,靠图标+文字表达,不要用 `theme.toolbar.activeBg`(`#e7e5df`/`#3a3631`)或 `theme.node.fill` 之类的灰色作为按钮填充底色。灰色 `activeBg` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
|
||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||
|
||||
|
||||
+53
-3
@@ -2,15 +2,66 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 提示词来源新增 Banana Prompt Quicker,并支持添加自定义标准 JSON 来源。
|
||||
+ [新增] 提示词中心新增「我的提示词」,支持本地管理、从公共库保存,并可在画布提示词库中直接使用。
|
||||
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。
|
||||
+ [调整] 内置提示词来源改为读取 Image Prompts 统一 JSON 数据,不再由画布端分别解析。
|
||||
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
|
||||
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
|
||||
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
|
||||
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
|
||||
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
|
||||
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
|
||||
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
|
||||
|
||||
## v0.9.0 - 2026-07-17
|
||||
|
||||
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
|
||||
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
|
||||
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
|
||||
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
|
||||
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
|
||||
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
|
||||
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
|
||||
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
|
||||
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
|
||||
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
|
||||
|
||||
## v0.8.2 - 2026-07-16
|
||||
|
||||
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
|
||||
+ [修复] 画布节点提示词输入框补上悬停文本光标。
|
||||
|
||||
## v0.8.1 - 2026-07-16
|
||||
|
||||
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
|
||||
+ [优化] 3D 全景(1.1.0)支持上传与 AI 生成并升级查看器;
|
||||
+ [优化] HTML 节点(1.2.0)迁移到统一交互开关;
|
||||
+ [优化] 便利贴(1.1.0)可拖动移动、自选颜色、移除资源角标与衍生功能;
|
||||
+ [优化] SVG 节点(1.1.0)透明背景融入画布、可拖动、去除默认值;
|
||||
+ [优化] 画布节点渲染性能:memo 化节点回调,交互时不再全量重渲染;
|
||||
+ [修复] 修复 Markdown 节点在点击/移动视角时重复渲染导致图片反复请求的问题;
|
||||
+ [修复] 修复插件版本号显示不更新,插件面板新增可升级绿点提醒。
|
||||
|
||||
## v0.8.0 - 2026-07-15
|
||||
|
||||
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
||||
+ [新增] 插件开发 SDK,可用 TypeScript 开发画布节点插件。
|
||||
+ [新增] 新增 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
+ [新增] 官方插件注册表:节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
+ [新增] 在画布右上角工具栏新增「节点插件」入口。
|
||||
+ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。
|
||||
|
||||
## v0.7.1 - 2026-07-15
|
||||
|
||||
+ [修复] 修复通过 `crypto.randomUUID` 不可用导致页面白屏报错的问题,改用 nanoid 生成 id。
|
||||
+ [修复] 修复页面白屏报错的问题。
|
||||
|
||||
## v0.7.0 - 2026-07-14
|
||||
|
||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
|
||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||
@@ -18,7 +69,6 @@
|
||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||
+ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。
|
||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
||||
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
||||
|
||||
@@ -14,5 +14,7 @@ FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=web-build /app/web/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
## 核心功能
|
||||
|
||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
||||
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
||||
- 插件系统:支持通过 URL 动态安装 / 启用 / 更新 / 卸载远程节点插件,并提供 TypeScript SDK 自行开发画布节点插件。
|
||||
- 自定义接口调用:可自定义生图 / 视频接口的调用方式,灵活适配各类中转站与自建服务。
|
||||
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
|
||||
|
||||
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
|
||||
@@ -68,16 +70,7 @@ docker compose up -d
|
||||
|
||||
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
||||
|
||||
## New API 自动配置
|
||||
|
||||
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
|
||||
|
||||
```text
|
||||
https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
```
|
||||
|
||||
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
|
||||
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
|
||||
如果默认的OpenAI接口调用方式与您的API不同,可自定义生图/视频脚本调用。
|
||||
|
||||
## 效果展示
|
||||
|
||||
@@ -108,20 +101,9 @@ https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
|
||||
## 赞助支持
|
||||
|
||||
<div align="center">
|
||||
本项目长期开放广告赞助合作,欢迎品牌 / 产品投放,你的支持是持续更新的动力!
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力!
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://ifdian.net/a/basketikun">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
有广告赞助意向请通过上方联系方式沟通。
|
||||
|
||||
## 社区支持
|
||||
|
||||
|
||||
+16
@@ -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.
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "tsx --test src/canvas-session.test.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"prepack": "npm run build"
|
||||
|
||||
+54
-29
@@ -11,11 +11,12 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
|
||||
type Json = Record<string, unknown>;
|
||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||
let codexThreadId = "";
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -30,52 +31,56 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
export function interruptCodexTurn() {
|
||||
if (!codexApp) return false;
|
||||
export function interruptCodexTurn(threadId?: string) {
|
||||
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
||||
return codexApp.interruptCurrentTurn();
|
||||
}
|
||||
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
options.onStart?.();
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
let threadId = await ensureCodexThread(codexApp, options, emit);
|
||||
const app = await getCodexApp(options.appEmit || emit);
|
||||
let threadId = await ensureCodexThread(app, options, emit);
|
||||
options.onThread?.(threadId);
|
||||
try {
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit);
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
options.onFinish?.();
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const thread = await codexApp.startThread(cwd);
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
return thread;
|
||||
}
|
||||
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
const thread = await codexApp.resumeThread(threadId, cwd);
|
||||
const thread = await app.resumeThread(threadId, cwd);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
codexThreadId = String(field(thread, "id") || threadId);
|
||||
return { thread, messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.listThreads({
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.listThreads({
|
||||
limit: options.limit || 40,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
@@ -97,9 +102,9 @@ export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: stri
|
||||
}
|
||||
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await codexApp.archiveThread(threadId);
|
||||
await app.archiveThread(threadId);
|
||||
}
|
||||
|
||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
@@ -131,7 +136,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
function isRecoverableThreadError(error: unknown) {
|
||||
export function isRecoverableThreadError(error: unknown) {
|
||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||
}
|
||||
|
||||
@@ -192,10 +197,11 @@ class CodexAppClient {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
async startTurn(threadId: string, prompt: string, images: string[]) {
|
||||
async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
|
||||
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
||||
const turnId = String(field(field(result, "turn"), "id") || "");
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
onTurn?.(turnId);
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
@@ -267,9 +273,9 @@ class CodexAppClient {
|
||||
} else if (turnId) {
|
||||
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
||||
}
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount });
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount, ...codexEventScope(params) });
|
||||
this.deltaCount = 0;
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage });
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,7 +284,7 @@ class CodexAppClient {
|
||||
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
||||
this.deltaCount += 1;
|
||||
this.textByItem.set(id, text);
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } });
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
|
||||
}
|
||||
|
||||
private answerServerRequest(message: Json) {
|
||||
@@ -321,23 +327,41 @@ function codexInput(prompt: string, images: string[]) {
|
||||
}
|
||||
|
||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
||||
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
||||
if (method === "turn/started") return { type: "turn.started" };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "error") return { type: "error", message: field(params, "message") };
|
||||
const scope = codexEventScope(params);
|
||||
if (method === "thread/started") return { type: "thread.started", ...scope };
|
||||
if (method === "turn/started") return { type: "turn.started", ...scope };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null, ...scope };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
|
||||
return null;
|
||||
}
|
||||
|
||||
function codexEventScope(params: Json) {
|
||||
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
||||
}
|
||||
|
||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.readThread(threadId, includeTurns);
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
return thread;
|
||||
}
|
||||
|
||||
async function getCodexApp(emit: AgentEmit) {
|
||||
if (codexApp) return codexApp;
|
||||
codexAppStart ||= CodexAppClient.start(emit);
|
||||
try {
|
||||
codexApp = await codexAppStart;
|
||||
return codexApp;
|
||||
} finally {
|
||||
codexAppStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||
@@ -458,6 +482,7 @@ function toolName(name: string) {
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
|
||||
test("MCP 读取当前激活网页的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
|
||||
session.activateClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
|
||||
session.activateClient("second");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("画布写操作只发送给当前激活网页", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "canvas_apply_ops");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
test("当前 turn 的图片附件可在发起标签页画布创建图片节点", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
t.after(() => first.close());
|
||||
const dataUrl = "data:image/png;base64,aW1hZ2U=";
|
||||
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", size: 5, width: 1200, height: 600, dataUrl }]);
|
||||
session.bindClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"], x: 100, y: 200 });
|
||||
const call = first.event("tool_call");
|
||||
const input = field(call, "input") as Record<string, unknown>;
|
||||
const nodes = input.nodes as Array<Record<string, unknown>>;
|
||||
assert.equal(field(call, "name"), "canvas_create_attachment_nodes");
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(nodes[0].attachmentId, "attachment-1");
|
||||
assert.equal(nodes[0].title, "商品.png");
|
||||
assert.deepEqual(nodes[0].position, { x: 100, y: 200 });
|
||||
assert.equal(nodes[0].width, 640);
|
||||
assert.equal(nodes[0].height, 320);
|
||||
assert.equal("dataUrl" in nodes[0], false);
|
||||
assert.equal(session.getTurnAttachment("first", "attachment-1").dataUrl, dataUrl);
|
||||
|
||||
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
const created = (await result) as { nodes: Array<{ id: string; attachmentId: string; title: string }> };
|
||||
assert.equal(created.nodes[0].id, nodes[0].id);
|
||||
assert.equal(created.nodes[0].attachmentId, "attachment-1");
|
||||
session.clearTurnAttachments("first");
|
||||
assert.throws(() => session.getTurnAttachment("first", "attachment-1"), /找不到/);
|
||||
});
|
||||
|
||||
test("图片附件只允许发起 turn 的标签页读取和落入画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", dataUrl: "data:image/png;base64,aW1hZ2U=" }]);
|
||||
session.bindClient("second");
|
||||
|
||||
await assert.rejects(session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"] }), /发起标签页/);
|
||||
assert.throws(() => session.getTurnAttachment("second", "attachment-1"), /发起标签页/);
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(second.event("tool_call"), undefined);
|
||||
});
|
||||
|
||||
test("tool result is accepted only from the request client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "first only" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
|
||||
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
|
||||
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
|
||||
assert.deepEqual(await result, { client: "first" });
|
||||
});
|
||||
|
||||
test("生成状态查询由当前激活网页返回", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("generation_get_status", { scope: "all" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "generation_get_status");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
|
||||
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
|
||||
});
|
||||
|
||||
test("活动网页关闭后回退到仍连接的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
});
|
||||
|
||||
test("closing the active client falls back to the most recently focused client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
const third = connect(session, "third");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
third.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.updateState(snapshot("canvas-third"), "third");
|
||||
session.activateClient("third");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
|
||||
});
|
||||
|
||||
test("closing a client rejects its pending tool requests", async () => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "pending" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
first.close();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
|
||||
]);
|
||||
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
|
||||
assert.match(outcome, /断开/);
|
||||
});
|
||||
|
||||
test("shared thread events are broadcast with the active thread id", (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
|
||||
|
||||
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||
});
|
||||
|
||||
test("new clients receive the current Codex state and later updates", (t) => {
|
||||
const session = new CanvasSession();
|
||||
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
const client = connect(session, "first");
|
||||
t.after(() => client.close());
|
||||
|
||||
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
|
||||
session.setCodexState({ busy: false });
|
||||
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
test("a bound client remains the tool target while focus changes", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.bindClient("first");
|
||||
session.activateClient("second");
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "bound" });
|
||||
const call = first.event("tool_call");
|
||||
assert.equal(second.event("tool_call"), undefined);
|
||||
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
|
||||
session.releaseClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("closing the bound client falls back to the active client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.bindClient("first");
|
||||
session.activateClient("second");
|
||||
first.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
|
||||
const call = second.event("tool_call");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
function connect(session: CanvasSession, clientId: string) {
|
||||
const response = new FakeSseResponse();
|
||||
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
||||
return response;
|
||||
}
|
||||
|
||||
function snapshot(projectId: string) {
|
||||
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
}
|
||||
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
class FakeSseResponse extends EventEmitter {
|
||||
private chunks: string[] = [];
|
||||
|
||||
writeHead() {
|
||||
return this;
|
||||
}
|
||||
|
||||
write(chunk: string) {
|
||||
this.chunks.push(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
event(type: string) {
|
||||
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
||||
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||
return data ? (JSON.parse(data) as unknown) : undefined;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import type { ServerResponse } from "node:http";
|
||||
|
||||
import { type ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
import type { AgentAttachment, CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
@@ -17,46 +19,141 @@ const SITE_TOOLS = new Set<ToolName>([
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
"generation_get_status",
|
||||
]);
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private clientFocusOrder = new Map<string, number>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasState: CanvasSnapshot | null = null;
|
||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||
private turnAttachments = new Map<string, TurnAttachment>();
|
||||
private activeClientId = "";
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
|
||||
private get canvasState() {
|
||||
return this.canvasStates.get(this.targetClientId) || null;
|
||||
}
|
||||
|
||||
private get targetClientId() {
|
||||
return this.boundClientId || this.activeClientId;
|
||||
}
|
||||
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size };
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||
}
|
||||
|
||||
get codexBusy() {
|
||||
return this.codexState.busy;
|
||||
}
|
||||
|
||||
setCodexState(patch: Partial<CodexState>) {
|
||||
this.codexState = { ...this.codexState, ...patch };
|
||||
this.emitAll("codex_state", this.codexState);
|
||||
}
|
||||
|
||||
openEvents(url: URL, res: ServerResponse) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
const statusOnly = url.searchParams.get("role") === "status";
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
if (!statusOnly) this.clients.set(clientId, res);
|
||||
sendEvent(res, "hello", { ok: true, clientId });
|
||||
if (!statusOnly) {
|
||||
this.clients.set(clientId, res);
|
||||
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||
if (!this.activeClientId) {
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
if (!statusOnly) this.clients.delete(clientId);
|
||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
||||
if (statusOnly || this.clients.get(clientId) !== res) return;
|
||||
this.clients.delete(clientId);
|
||||
this.clientFocusOrder.delete(clientId);
|
||||
this.canvasStates.delete(clientId);
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
this.pending.forEach((item, requestId) => {
|
||||
if (item.clientId !== clientId) return;
|
||||
this.pending.delete(requestId);
|
||||
item.reject(new Error("请求页面已断开"));
|
||||
});
|
||||
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||
});
|
||||
}
|
||||
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
|
||||
const targetClientId = clientId || this.activeClientId;
|
||||
if (!targetClientId) return;
|
||||
this.canvasStates.set(targetClientId, { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot);
|
||||
}
|
||||
|
||||
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
activateClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
|
||||
bindClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.boundClientId = clientId;
|
||||
}
|
||||
|
||||
releaseClient(clientId: string) {
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
}
|
||||
|
||||
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
|
||||
this.turnAttachments.clear();
|
||||
return attachments.flatMap((item, index) => {
|
||||
if (!item.dataUrl?.startsWith("data:image/")) return [];
|
||||
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
|
||||
const attachment: TurnAttachment = {
|
||||
clientId,
|
||||
id,
|
||||
name: item.name?.trim() || `图片 ${index + 1}`,
|
||||
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
|
||||
size: positiveNumber(item.size, 0),
|
||||
width: positiveNumber(item.width, 1024),
|
||||
height: positiveNumber(item.height, 1024),
|
||||
dataUrl: item.dataUrl,
|
||||
};
|
||||
this.turnAttachments.set(id, attachment);
|
||||
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
|
||||
});
|
||||
}
|
||||
|
||||
clearTurnAttachments(clientId?: string) {
|
||||
this.turnAttachments.forEach((item, id) => {
|
||||
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
getTurnAttachment(clientId: string, attachmentId: string) {
|
||||
const attachment = this.turnAttachments.get(attachmentId);
|
||||
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
|
||||
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
|
||||
return attachment;
|
||||
}
|
||||
|
||||
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId) return;
|
||||
if (!item || !body.requestId || item.clientId !== clientId) return false;
|
||||
this.pending.delete(body.requestId);
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
return true;
|
||||
}
|
||||
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||
this.emitAll(type, { ...payload, threadId });
|
||||
}
|
||||
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
@@ -72,6 +169,7 @@ export class CanvasSession {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
if (tool === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
|
||||
if (tool === "canvas_create_node") {
|
||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||
input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] };
|
||||
@@ -166,9 +264,36 @@ export class CanvasSession {
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
|
||||
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
|
||||
const clientId = this.targetClientId;
|
||||
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
|
||||
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
|
||||
const x = Number(input.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(input.y ?? 0);
|
||||
const gap = Number(input.gap ?? 40);
|
||||
const direction = input.direction || "row";
|
||||
let offset = 0;
|
||||
const nodes = attachments.map((attachment) => {
|
||||
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
|
||||
const node = {
|
||||
id: `image-${crypto.randomUUID()}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.name,
|
||||
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
offset += (direction === "row" ? size.width : size.height) + gap;
|
||||
return node;
|
||||
});
|
||||
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
|
||||
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
|
||||
}
|
||||
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
|
||||
const clientId = this.targetClientId;
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -176,7 +301,7 @@ export class CanvasSession {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -263,3 +388,13 @@ function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefi
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown, fallback: number) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
|
||||
function fitAttachmentNodeSize(width: number, height: number) {
|
||||
const scale = Math.min(1, 640 / width, 640 / height);
|
||||
return { width: width * scale, height: height * scale };
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ 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 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
||||
export const AGENT_PROMPT = "你正在帮助用户操作 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。本轮若有用户上传的图片附件,会同时给出 attachmentId;用户要求把附件放入画布或作为生成参考图时,必须先用 canvas_create_attachment_nodes 创建真实图片节点,再把返回的节点 ID 传给 canvas_create_generation_flow.referenceNodeIds,不要创建空图片占位节点。若当前不在画布页,画布工具会报错,需先用 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 SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
||||
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
|
||||
|
||||
+102
-22
@@ -2,7 +2,7 @@ import express, { type NextFunction, type Request, type Response } from "express
|
||||
|
||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
||||
import type { AgentAttachment } from "./types.js";
|
||||
|
||||
export function startHttpServer() {
|
||||
@@ -12,7 +12,16 @@ export function startHttpServer() {
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
|
||||
const emit = (type: string, payload: unknown) => {
|
||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
|
||||
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
||||
};
|
||||
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
||||
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||
return workspace;
|
||||
};
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "30mb" }));
|
||||
@@ -33,10 +42,21 @@ export function startHttpServer() {
|
||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
session.resolveResult(req.body);
|
||||
app.post("/canvas/activate", (req, res) => {
|
||||
session.activateClient(String(req.query.clientId || ""));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
|
||||
res.status(ok ? 200 : 409).json({ ok });
|
||||
});
|
||||
app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
|
||||
const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
|
||||
const data = attachment.dataUrl.split(",", 2)[1];
|
||||
if (!data) throw new Error("图片附件内容无效");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.type(attachment.type).send(Buffer.from(data, "base64"));
|
||||
}));
|
||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||
app.get("/agent/codex/workspace", (_req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
@@ -48,48 +68,102 @@ export function startHttpServer() {
|
||||
res.json({ ok: true, workspace, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/new", route(async (_req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateSiteWorkspace(config, { activeThreadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
|
||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
try {
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
} catch (error) {
|
||||
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
|
||||
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
|
||||
}
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
|
||||
const nextWorkspace = setActiveThread(threadId);
|
||||
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined });
|
||||
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
app.post("/agent/codex/turn", route(async (req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
const prompt = String(req.body?.prompt || "");
|
||||
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
try {
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
let turnId = "";
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
setActiveThread(threadId, { emptyThread: true });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
setActiveThread(threadId);
|
||||
}
|
||||
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
|
||||
const chatMessage = {
|
||||
sourceClientId: clientId,
|
||||
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
|
||||
};
|
||||
let chatThreadId = "";
|
||||
const turnEmit = (type: string, payload: unknown) => {
|
||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
session.emitThread(type, threadId, data);
|
||||
};
|
||||
void runCodexTurn(withAgentPrompt(withAttachmentContext(prompt, attachmentRefs)), turnEmit, attachments, {
|
||||
threadId,
|
||||
cwd: workspace.workspacePath,
|
||||
appEmit: emit,
|
||||
onStart: clientId ? () => session.bindClient(clientId) : undefined,
|
||||
onThread: (actualThreadId) => {
|
||||
if (actualThreadId !== threadId) {
|
||||
threadId = actualThreadId;
|
||||
setActiveThread(threadId, { emptyThread: true });
|
||||
}
|
||||
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||
if (chatThreadId !== threadId) {
|
||||
chatThreadId = threadId;
|
||||
session.emitThread("chat_message", threadId, chatMessage);
|
||||
}
|
||||
},
|
||||
onTurn: (actualTurnId) => {
|
||||
turnId = actualTurnId;
|
||||
session.setCodexState({ busy: true, threadId, turnId });
|
||||
},
|
||||
onFinish: () => {
|
||||
session.clearTurnAttachments(clientId);
|
||||
if (clientId) session.releaseClient(clientId);
|
||||
session.setCodexState({ busy: false, threadId, turnId });
|
||||
},
|
||||
});
|
||||
res.json({ ok: true, threadId });
|
||||
} catch (error) {
|
||||
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
throw error;
|
||||
}
|
||||
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();
|
||||
app.post("/agent/codex/interrupt", (req, res) => {
|
||||
const ok = interruptCodexTurn(String(req.body?.threadId || ""));
|
||||
res.json({ ok });
|
||||
});
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
@@ -141,3 +215,9 @@ function validToken(req: Request, url: URL, token: string) {
|
||||
const header = req.headers["x-canvas-agent-token"];
|
||||
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
||||
}
|
||||
|
||||
function withAttachmentContext(prompt: string, attachments: Array<{ id: string; name: string }>) {
|
||||
if (!attachments.length) return prompt;
|
||||
const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
|
||||
return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export const toolNames = [
|
||||
"canvas_export_snapshot",
|
||||
"canvas_apply_ops",
|
||||
"canvas_create_node",
|
||||
"canvas_create_attachment_nodes",
|
||||
"canvas_create_text_node",
|
||||
"canvas_create_text_nodes",
|
||||
"canvas_create_config_node",
|
||||
@@ -32,6 +33,7 @@ export const toolNames = [
|
||||
"canvas_select_nodes",
|
||||
"canvas_set_viewport",
|
||||
"canvas_run_generation",
|
||||
"generation_get_status",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
@@ -93,6 +95,7 @@ export const toolInputSchemas = {
|
||||
canvas_export_snapshot: z.object({}).passthrough(),
|
||||
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
|
||||
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
|
||||
canvas_create_attachment_nodes: z.object({ attachmentIds: z.array(z.string()).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
|
||||
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
@@ -111,6 +114,7 @@ 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() }),
|
||||
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
|
||||
workbench_image_get_config: z.object({}).passthrough(),
|
||||
workbench_image_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(),
|
||||
@@ -128,6 +132,7 @@ export const toolDescriptions: Record<ToolName, string> = {
|
||||
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
||||
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
|
||||
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
|
||||
canvas_create_attachment_nodes: "把当前对话中用户上传的图片附件创建成真实画布图片节点。attachmentIds 使用本轮附件清单中的 ID;返回的节点 ID 可传给 canvas_create_generation_flow.referenceNodeIds 作为生成参考图。",
|
||||
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
||||
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
|
||||
@@ -146,10 +151,11 @@ export const toolDescriptions: Record<ToolName, string> = {
|
||||
canvas_select_nodes: "设置当前选中节点。",
|
||||
canvas_set_viewport: "调整画布视口。",
|
||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
|
||||
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||
|
||||
@@ -5,4 +5,4 @@ export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; pos
|
||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string };
|
||||
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -5,3 +5,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
|
||||
# environment:
|
||||
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
|
||||
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
|
||||
|
||||
@@ -24,6 +24,7 @@ description: 当前画布节点的主要用途与操作流程
|
||||
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
||||
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
||||
- 输入内容可以手写,也可以从提示词库选择。
|
||||
- 输入框支持 `@` 引用已连接的图片、文本、视频、音频资源;`@` 引用图片时会在输入框内直接显示真实缩略图,发送时按当前连接自动编号交给模型。因此不需要再记忆「图片1 / 文本1」编号,画布节点右上角也不再显示资源角标。
|
||||
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
||||
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"canvas-data-structure"
|
||||
"canvas-data-structure",
|
||||
"local-codex-canvas"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,3 +50,42 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
## 数据说明
|
||||
|
||||
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||
|
||||
## 统计分析(可选)
|
||||
|
||||
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
|
||||
|
||||
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID(`G-XXXX`) |
|
||||
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID(`hm.js?` 后面那串 hash) |
|
||||
|
||||
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
ANALYTICS_GA4_ID: G-XXXXXXXXXX
|
||||
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
|
||||
```
|
||||
|
||||
或使用 `docker run`:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
|
||||
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
|
||||
ghcr.io/basketikun/infinite-canvas:latest
|
||||
```
|
||||
|
||||
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
|
||||
|
||||
<Callout type="warn">
|
||||
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
|
||||
</Callout>
|
||||
|
||||
@@ -85,9 +85,12 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 生成数量。
|
||||
- 视频模型。
|
||||
- 视频比例、清晰度、时长、生成声音和水印。
|
||||
- 自定义生图 / 视频接口的调用方式,灵活适配不同中转站与自建服务。
|
||||
|
||||
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
||||
|
||||
节点下方对话框和组装提示词输入框都支持 `@` 引用已连接的图片、文本、视频、音频等资源;`@` 引用图片时,输入框内会直接显示该图片的真实缩略图,而不再是「图片1」这类文字编号,发送时会按当前连接自动编号交给模型理解。由于引用改在对话框内直接 `@`,画布节点右上角不再显示「图片1 / 文本1」资源角标。
|
||||
|
||||
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
||||
|
||||
## 画布助手
|
||||
@@ -106,6 +109,18 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 把助手生成的图片插入画布。
|
||||
- 折叠和展开助手面板。
|
||||
|
||||
## 节点插件系统
|
||||
|
||||
画布支持通过节点插件扩展新的节点类型,内置节点之外的能力都可以以插件形式加载:
|
||||
|
||||
- 支持通过 URL 动态安装、启用、更新和卸载远程节点插件。
|
||||
- 提供官方插件注册表,节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
- 提供 TypeScript 插件开发 SDK,可自行开发画布节点插件,并使用 AI 生成、面板控制等扩展能力。
|
||||
- 内置 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
- 插件面板会显示插件版本,并对可升级的插件给出提醒。
|
||||
|
||||
Codex App 插件的安装与使用见 [Codex App 插件](/docs/overview/codex-app-plugin)。
|
||||
|
||||
## 提示词库
|
||||
|
||||
前台提示词库支持:
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"render",
|
||||
"docker",
|
||||
"third-party-prompt-repositories",
|
||||
"[在线体验](https://canvas.best/)",
|
||||
"local-codex-canvas"
|
||||
"[在线体验](https://canvas.best/)"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ description: 当前已接入同步逻辑的第三方提示词仓库
|
||||
|
||||
| 地址 | 状态 |
|
||||
| --- | --- |
|
||||
| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
|
||||
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"pages": [
|
||||
"[更新日志](/docs/progress/changelog)",
|
||||
"local-agent-integration-plan",
|
||||
"prompt-chip-input-plan",
|
||||
"pending-test",
|
||||
"todo"
|
||||
]
|
||||
|
||||
@@ -5,41 +5,14 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
|
||||
- 本地 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 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。
|
||||
- 画布 Agent:Codex 旧 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。
|
||||
- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源。
|
||||
- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转。
|
||||
- 前端构建:修复 TypeScript 严格检查下的构建报错。
|
||||
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。
|
||||
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
|
||||
- 环境变量:移除过期的后端 `.env.example` 示例文件。
|
||||
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
|
||||
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
|
||||
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
|
||||
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
|
||||
- 提示词来源:6 个内置来源应从 Image Prompts 统一仓库读取,更新后数量依次为 323、494、53、76、126、129;提示词仍按 6 个来源分组并可独立启用,来源内可继续按 `tags` 筛选。添加标准 JSON URL 后应能查看内容,填写非数组 JSON 或不可访问地址时应显示失败,并继续保留该来源上一次成功缓存的内容。
|
||||
- 提示词来源界面:来源应以卡片列表展示,启用开关位于左侧,数量、同步状态和上次成功时间作为次级信息显示,查看、拉取及自定义来源编辑/删除操作使用带文字按钮;底部定时拉取区域应保持独立边框布局。
|
||||
- 我的提示词:在提示词中心切换到「我的提示词」,验证新增、编辑、删除和搜索;从提示词库保存后应出现在个人列表、画布左侧提示词面板和提示词选择弹窗中,刷新页面后数据仍保留在本地,Agent 搜索提示词时也应能查到。
|
||||
- 画布提示词库:不展开任何公共来源直接搜索其中的提示词,匹配项应自动显示;点击「插入画布」后应创建正文正确且标题保持为提示词标题的文本节点。
|
||||
- 全站 Agent:新增 `generation_get_status` 工具,画布生成节点可按 `nodeIds` 查询,生图和视频工作台提交后会返回 `taskId` 并可查询排队、运行、成功或失败状态;需验证查询只由当前活动标签页返回。
|
||||
- 本地 Agent 多标签页隔离:同时打开两个不同画布并连接同一个 Agent,分别聚焦标签页后通过 MCP 读取和修改画布,操作应只落在当前聚焦页面;网页面板发起的整个 Codex turn 应固定操作发起页面,即使中途聚焦另一标签页也不能切换目标;关闭当前页面后应回退到最近聚焦且仍连接的页面,其他页面回传同一请求结果应被拒绝。
|
||||
- 本地 Agent 多标签页会话同步:所有标签页共享同一个站点级 Codex 活跃线程;任一页面发送消息、新建、恢复或删除会话后,其他页面应同步活跃线程和聊天记录;Agent 输出仅显示在事件所属线程,运行中不能新建、恢复、删除或再次发送任务。
|
||||
- 本地 Agent 运行状态同步:在一个标签页运行较长 Codex 任务,等待某张工具卡显示「工具完成」后再打开或刷新第二个标签页;第二个标签页应立即显示 Codex 正在运行并禁用发送,整轮结束后两个标签页同时恢复;工具卡只显示「工具完成」,整轮结束由「本轮完成」表示。
|
||||
- 本地 Agent 图片附件落画布:在右侧 Agent 上传参考图并要求基于商品信息创建生图流程,附件应创建为保持原比例的真实图片节点,分析提示词应创建为文本节点,二者都应连接到生成配置节点;刷新页面后参考图仍可显示并参与生成。任务中途切换到其他标签页时,附件只能写入发起任务的标签页;若发起标签页关闭,附件读取应失败且不能落入其他画布。
|
||||
- Agent 对话滚动:从历史或日志切回对话、恢复其他会话时应自动定位到最新消息;手动向上浏览后应显示向下箭头且新消息不强制打断阅读,点击箭头后平滑回到底部并继续跟随新消息。
|
||||
- Agent 消息区分:用户消息应在右侧以主题浅底气泡显示,AI 回复应在左侧保留 Agent 图标并使用开放式 Markdown 排版;长文本、多张附件、错误消息及浅色/深色主题下均应清晰且不溢出。
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 提示词面板引用显示图片缩略图规划
|
||||
description: 将提示词面板输入框从 textarea 改造为 contentEditable,让 @ 引用直接显示真实图片缩略图
|
||||
---
|
||||
|
||||
# 提示词面板 @ 引用显示真实图片缩略图
|
||||
|
||||
## 背景 / 要解决的问题
|
||||
|
||||
提示词面板(`CanvasNodePromptPanel`)的输入框里,`@` 引用一张图片后,显示的是蓝色文字 chip「图片1」。期望**直接显示这张图片的真实缩略图**,而不是文字编号。
|
||||
|
||||
当前该输入框是原生 `<textarea>`,只能承载纯文本,无法内嵌图片。要显示缩略图,必须改用 `contentEditable`(可编辑 div),把引用做成带 `<img>` 的原子小卡片。项目里「组装提示词」组件(`CanvasConfigComposer`)已经是这套 contentEditable + 图片 chip 的成熟实现,可作蓝本。
|
||||
|
||||
## 关键约束(为什么不能直接改现有组件)
|
||||
|
||||
`CanvasResourceMentionTextarea` 被两处复用:
|
||||
|
||||
1. **提示词面板** `web/src/components/canvas/canvas-node-prompt-panel.tsx` —— 本次要改的对象。`highlightLabels` 默认 true,value 是提示词字符串,引用以 label 文本「图片1」嵌在其中,**直接发给 AI**(配合 `web/src/lib/image-reference-prompt.ts` 的「参考图片编号:图片1」前缀)。
|
||||
2. **节点内文本编辑** `web/src/components/canvas/canvas-node.tsx`(`isEditingContent` 分支)—— `highlightLabels={false}`,通过 `ref` 调用 `textarea.setSelectionRange(...)` 等 **textarea 专有 API**。若把组件改成 contentEditable,这处会直接崩。
|
||||
|
||||
> 结论:**新建一个 contentEditable chip 输入组件,只替换提示词面板那一处**;`CanvasResourceMentionTextarea`(textarea 版)保持不动,继续服务节点内文本编辑。
|
||||
|
||||
## 方案:新增 `CanvasPromptChipInput` 组件
|
||||
|
||||
新文件 `web/src/components/canvas/canvas-prompt-chip-input.tsx`,参照 `canvas-config-composer.tsx` 的 contentEditable 模式,但有一处**核心差异**:
|
||||
|
||||
- 「组装提示词」把引用序列化成 `@[node:UUID]` 标记;
|
||||
- 本组件的 value 必须保持**引用的 label 文本**(如「图片1」),这样发给生成的提示词语义不变。因此 chip ↔ 文本的序列化用 **label 文本**,不是 node 标记。
|
||||
|
||||
### 组件契约(与现用法一致)
|
||||
|
||||
Props:`value: string`、`references: CanvasResourceReference[]`、`onChange(value)`、`onSubmit?()`、`className`、`style`、`placeholder`。图片缩略图取 `reference.previewUrl`(即 `node.metadata.content`,已存在于 `CanvasResourceReference`)。
|
||||
|
||||
### 实现要点(大量复用 composer 的做法)
|
||||
|
||||
1. **DOM ← value(未聚焦时重建)**:仿 `CanvasConfigComposer` 的重建 effect。用 active label 列表(`references.filter(active).map(label)`,按长度降序)把 value 字符串切成「文本片段 + 命中 label」,命中处插入 chip。聚焦时不重建(`document.activeElement === editor` 直接 return),避免与输入 / IME 打架。
|
||||
2. **chip 渲染**:仿 `createReferenceChip`。image 类型 → `size-6` 的 `<img src={reference.previewUrl}>`;其它类型 → 截断文字。chip 为 `contentEditable="false"` 的 `<span>`,用 `dataset.refLabel` 存 label 以便序列化。
|
||||
3. **value ← DOM(syncFromEditor)**:仿 `serializeNodes`,但 chip 序列化为 `dataset.refLabel`(label 文本)而非 node 标记;文本节点原样拼接,`<br>` → `\n`。
|
||||
4. **@ 菜单**:复用 `textBeforeCaret` + `/@([^\s@]*)$/` 检测;菜单定位改用 `getSelection().getRangeAt(0).getBoundingClientRect()`(比 textarea 的镜像 div 更简单、天然贴着光标)。菜单 UI 沿用现有 `MentionMenu` 的视觉(缩略图 + label + 副标题)。
|
||||
5. **原子删除**:直接复用 composer 的 `deleteAdjacentReference` / `adjacentReferenceNode` / `findReferenceSibling`——contentEditable + `contentEditable="false"` chip 天然整体删除。可把这几个通用 DOM 助手连同 `textBeforeCaret` / `placeCaretAtEnd` / `closestEditor` 复制进新组件(保持 composer 不动,规避回归风险)。
|
||||
6. **回车发送 + IME**:复用 `isPlainEnterKey` / `isImeComposing`(`@/lib/keyboard-event`)与 `onCompositionStart/End`,发送时调用 `onSubmit`。
|
||||
7. **占位符 & 样式**:空值时显示 placeholder(absolute 占位层);容器套用面板传入的 `className/style`(`min-h-40`、透明底、无边框,与当前一致)。
|
||||
|
||||
### 替换点
|
||||
|
||||
`canvas-node-prompt-panel.tsx` 里把 `<CanvasResourceMentionTextarea .../>` 换成 `<CanvasPromptChipInput .../>`(同样的 value/references/onChange/onSubmit/className/style/placeholder)。其余(模型选择、设置、生成按钮)不动。
|
||||
|
||||
### 不改动
|
||||
|
||||
- `CanvasResourceMentionTextarea`(textarea 版)保留,节点内文本编辑继续用它。
|
||||
- 生成链路、`image-reference-prompt.ts` 前缀逻辑不变(value 仍是 label 文本)。
|
||||
|
||||
## 影响文件
|
||||
|
||||
- 新增:`web/src/components/canvas/canvas-prompt-chip-input.tsx`
|
||||
- 修改:`web/src/components/canvas/canvas-node-prompt-panel.tsx`(仅替换输入框组件 + 相应 import)
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npx tsc --noEmit` 通过。
|
||||
2. dev server 刷新后:
|
||||
- 连一张图片到图片 / 视频节点 → 面板输入框输入 `@` → 菜单贴着光标弹出 → 选中 → **输入框内显示该图的真实缩略图 chip**(不再是「图片1」文字)。
|
||||
- 光标在 chip 后按一次 Backspace → 整个 chip 一次删除。
|
||||
- 普通文字 + chip 混排,回车触发生成;确认发给生成的 prompt 里引用仍是「图片1」文本(生成结果正常引用到图)。
|
||||
- 中文输入法输入不吞字、不误触发发送。
|
||||
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
title: 打赏支持
|
||||
description: 支持无限画布项目继续维护
|
||||
---
|
||||
|
||||
# 打赏支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
|
||||
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
|
||||
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -4,7 +4,6 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"security",
|
||||
"donate",
|
||||
"sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
---
|
||||
title: 广告赞助
|
||||
description: 广告赞助、项目露出和社区推广说明
|
||||
title: 赞助支持
|
||||
description: 赞助支持、广告合作、项目露出和社区推广说明
|
||||
---
|
||||
|
||||
# 广告赞助
|
||||
# 赞助支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过 Star、广告赞助合作、提供 AI 订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。相关支持会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 通过广告赞助合作支持项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
## 广告赞助
|
||||
|
||||
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
||||
|
||||
## 适合的赞助内容
|
||||
### 适合的赞助内容
|
||||
|
||||
- AI 工具、模型服务、API 服务或开发者工具。
|
||||
- 与图片、视频、文本生成工作流相关的产品。
|
||||
- 适合开源项目用户和 AI 创作者使用的服务。
|
||||
|
||||
## 建议提供的信息
|
||||
### 建议提供的信息
|
||||
|
||||
- 品牌或产品名称。
|
||||
- 希望展示的位置和周期。
|
||||
|
||||
+2
-3
@@ -27,8 +27,7 @@
|
||||
## 支持与安全
|
||||
|
||||
- [漏洞提交](/docs/support/security)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
- [赞助支持](/docs/support/sponsor)
|
||||
|
||||
## 项目进度
|
||||
|
||||
@@ -43,4 +42,4 @@
|
||||
|
||||
## 原理说明
|
||||
|
||||
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
|
||||
- [本地 Codex 连接画布原理](/docs/development/local-codex-canvas)
|
||||
|
||||
@@ -10,7 +10,7 @@ const tabs = [
|
||||
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
|
||||
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
||||
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
||||
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
|
||||
{ title: '赞助支持', href: '/docs/support/sponsor', prefix: '/docs/support' },
|
||||
];
|
||||
|
||||
export function DocsTopTabs() {
|
||||
|
||||
@@ -7,4 +7,9 @@ server {
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
# Infinite Canvas 画布节点插件
|
||||
|
||||
给画布扩展自定义节点。每个插件是一个**独立目录**,用 **TypeScript** 编写,自带 `package.json` / `build.mjs` / `src/index.tsx` / `dist/`,互不耦合,可单独构建、发布、升级。
|
||||
|
||||
内置节点只有文本、图片、视频、音频、生成配置、组六种;其余节点(Markdown、SVG、HTML、3D 全景、便利贴……)都是插件。
|
||||
|
||||
作者只写节点 UI 与逻辑,**类型、JSX、宿主 React、构建全部由 [`@infinite-canvas/plugin-sdk`](./sdk/README.md) 提供**,写 TSX 全程有代码提示;产物仍是宿主加载器现有契约的 ESM(React external、宿主单例)。
|
||||
|
||||
## 目录约定
|
||||
|
||||
```
|
||||
plugins/canvas/
|
||||
sdk/ # 插件 SDK(类型 + automatic JSX 运行时 + 构建助手)
|
||||
template/ # 起步模板:复制它开始写新插件
|
||||
markdown/ # 每个插件一个独立目录
|
||||
package.json
|
||||
build.mjs # 一行 buildPlugin,产物名取目录名 → dist/markdown.js
|
||||
tsconfig.json
|
||||
src/index.tsx # 插件源码(默认导出 definePlugin(...))
|
||||
README.md
|
||||
svg/ html/ panorama/ sticky-note/ ...
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cp -r plugins/canvas/template plugins/canvas/my-plugin
|
||||
cd plugins/canvas/my-plugin
|
||||
# 改 package.json 的 name;改 src/index.tsx 里的 id / nodes[].type
|
||||
npm install
|
||||
npm run dev # watch 构建,产物同步到 web/public/plugins/my-plugin.js
|
||||
npm run typecheck # tsc --noEmit,类型自检
|
||||
```
|
||||
|
||||
产物名取**目录名**,复制后记得把目录改成插件名。
|
||||
|
||||
## 构建 / 发布 / 升级
|
||||
|
||||
```bash
|
||||
cd plugins/canvas/<name>
|
||||
npm install
|
||||
npm run build # → dist/<name>.js,并同步到 web/public/plugins/<name>.js
|
||||
npm run dev # watch,改动自动构建并同步
|
||||
```
|
||||
|
||||
把 `dist/<name>.js` 托管到任意静态地址(CDN、GitHub Raw、对象存储),用户在画布「节点插件」管理器填该 URL 安装。升级时重新构建覆盖同一 URL,用户点「更新」即可。
|
||||
|
||||
## 官方插件注册表
|
||||
|
||||
本项目官方插件由 CI 集中构建后发布到孤儿分支 `plugins-dist`(**构建产物不进 git**),画布「节点插件」面板顶部的**官方插件**区经 jsDelivr 从该分支远程拉取并一键安装;第三方插件仍走下方「第三方插件」的 JS URL 安装。构建脚本与发布说明见 [`registry/`](./registry/README.md);清单地址可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖成自建来源。
|
||||
|
||||
## 本地开发
|
||||
|
||||
`npm run dev` 起 watch,产物会同步到 `web/public/plugins/<name>.js`。此后有两种方式在画布里用到它:
|
||||
|
||||
**方式一(推荐):自动发现。** 画布启动时会扫描 `web/public/plugins/` 下的插件,自动加入「节点插件」管理器列表,**默认关闭**;打开开关即启用。无需手动填 URL,启用时会按文件重新拉取,配合 watch 改完刷新即最新。
|
||||
|
||||
**方式二:`VITE_DEV_PLUGINS`。** 在 `web/.env.local` 声明(逗号分隔多个),这些插件每次刷新页面都**重新拉取并直接激活**(不缓存、不落库、无开关):
|
||||
|
||||
```env
|
||||
VITE_DEV_PLUGINS=/plugins/markdown.js,/plugins/svg.js
|
||||
```
|
||||
|
||||
再起画布 `web`(`npm run dev`)。流程即:改 `src/index.tsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。
|
||||
|
||||
## 用 SDK 写插件
|
||||
|
||||
默认导出 `definePlugin({...})`(对象形式,**无需再 `const { React } = runtime`**):
|
||||
|
||||
```tsx
|
||||
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
function Content({ ctx }: CanvasNodeContentProps) {
|
||||
const [n, setN] = useState(0);
|
||||
return (
|
||||
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||
{ctx.node.title}: {n}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "my-plugin",
|
||||
name: "我的插件",
|
||||
version: "1.0.0",
|
||||
css: "…", // 可选:插件样式,自动注入/清理
|
||||
nodes: [ /* CanvasNodeDefinition[] */ ],
|
||||
setup(app) { return () => {}; }, // 可选,返回清理函数;app 含 injectCSS/emit/on
|
||||
});
|
||||
```
|
||||
|
||||
SDK 导出的 hooks(`useState/useEffect/useMemo/useRef/...`)运行时转发宿主 React,类型来自 `@types/react`。SDK 与依赖接入见 [`sdk/README.md`](./sdk/README.md)。
|
||||
|
||||
### CanvasNodeDefinition
|
||||
|
||||
```ts
|
||||
{
|
||||
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||
title: string; // 创建菜单/默认标题
|
||||
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||
description?: string;
|
||||
defaultSize: { width, height };
|
||||
defaultMetadata?: object; // 新建节点初始 metadata(文本内容放 content)
|
||||
minimapColor?: string;
|
||||
showInCreateMenu?: boolean; // 默认 true
|
||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||
keepAspectRatio?: (node) => boolean;
|
||||
resource?: (node) => { kind: "text"|"image"|"video"|"audio", text?, url? } | null; // 作为上游输入被消费时输出什么
|
||||
Content: ({ ctx }) => ReactNode; // 节点主体渲染
|
||||
Panel?: ({ ctx, onClose }) => ReactNode; // 可选:节点下方面板
|
||||
toolbar?: (ctx) => Array<{ id, title, label, icon, onClick, danger? }>; // 追加到 hover 工具栏
|
||||
onDoubleClick?: (ctx) => boolean; // 返回 true 表示已处理双击
|
||||
}
|
||||
```
|
||||
|
||||
### ctx:节点与画布交互接口
|
||||
|
||||
`Content` / `Panel` / `toolbar` 都会拿到 `ctx`(类型 `CanvasNodeContext`):
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| `ctx.node` | 当前节点数据(含 `metadata.content` 等) |
|
||||
| `ctx.theme` / `ctx.scale` | 当前画布主题 token 与缩放,用来让 UI 跟随主题 |
|
||||
| `ctx.updateMetadata(patch)` | 更新自身 metadata(如保存内容) |
|
||||
| `ctx.updateNode(patch)` | 更新自身 title/width/height |
|
||||
| `ctx.getNode(id)` / `ctx.getNodes()` / `ctx.getConnections()` | 读画布 |
|
||||
| `ctx.getUpstream()` / `ctx.getDownstream()` | 取上/下游相连节点 |
|
||||
| `ctx.applyOps(ops)` | 用画布指令集增删节点/连线、选择、触发生成(见下) |
|
||||
| `ctx.emit(event, payload)` / `ctx.on(event, handler)` | 节点/插件间事件通信 |
|
||||
| `ctx.storage` | 插件私有持久化(按插件 id 命名空间) |
|
||||
|
||||
> `metadata` 的**内置字段**(content、status、model…)是强类型;插件写入的**自定义字段**读出为 `unknown`,按需 `as` 断言(参考 `sticky-note` 的 `pluginColor`)。
|
||||
|
||||
### 画布指令集(ctx.applyOps)
|
||||
|
||||
```ts
|
||||
ctx.applyOps([
|
||||
{ type: "add_node", id?, nodeType, title?, x?, y?, width?, height?, metadata? },
|
||||
{ type: "update_node", id, patch?, metadata? },
|
||||
{ type: "delete_node", id? | ids? },
|
||||
{ type: "connect_nodes", fromNodeId, toNodeId },
|
||||
{ type: "delete_connections", id? | ids? | all? },
|
||||
{ type: "select_nodes", ids },
|
||||
{ type: "set_viewport", viewport },
|
||||
{ type: "run_generation", nodeId, mode?, prompt? },
|
||||
]);
|
||||
```
|
||||
|
||||
## 重依赖 / 资源
|
||||
|
||||
- **重依赖**(three.js、marked 等):不要打进 bundle,运行时 `await import("https://esm.sh/...")` 动态加载(esbuild 自动 external);在 `src/env.d.ts` 声明该模块以通过 tsc。参考 `panorama/`、`markdown/`。
|
||||
- **CSS**:写独立 `.css`,`import css from "./styles.css"` 拿到字符串(esbuild `text` loader),放到 `css` 字段自动注入/清理;`src/env.d.ts` 声明 `*.css`。参考 `markdown/`。
|
||||
- **HTML**:HTML 节点把 HTML 字符串塞进 sandbox iframe 的 `srcDoc`,自带 `<style>`,不需要插件级 CSS。参考 `html/`。
|
||||
|
||||
## 兼容说明
|
||||
|
||||
加载器仍接受**默认导出为工厂函数** `(runtime) => CanvasPlugin`(用 `runtime.React`)或**普通对象**;老的 JS 插件无需改动即可运行。SDK 的 automatic JSX 让新插件走对象形式,更简洁。
|
||||
|
||||
## 注意
|
||||
|
||||
- 插件代码会在画布页面内**直接执行**,可访问浏览器本地数据(含 AI API Key)。发布前请自审,用户也只应安装可信来源。
|
||||
- 交互控件记得 `onMouseDown={(e) => e.stopPropagation()}`(避免触发节点拖拽),滚动区域加 `onWheel={(e) => e.stopPropagation()}` 与容器 `data-canvas-no-zoom`(避免被画布缩放拦截)。
|
||||
@@ -0,0 +1,17 @@
|
||||
# HTML 节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:用沙箱 iframe 渲染 HTML。源码里的 `{{input}}` 会被替换为上游文本节点内容。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/html.js,并同步到 web/public/plugins/html.js
|
||||
npm run dev # watch
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/html.js`(或托管后的公网 URL)。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
Generated
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas HTML 节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
||||
// 交互:预览态 iframe 默认 pointer-events:none —— 鼠标事件穿透到宿主节点体,
|
||||
// 因此点节点任意位置都能拖动,无需在节点上加任何标题栏/按钮。
|
||||
// 「编辑/预览」与「交互」开关都放在节点外的悬浮工具条(toolbar 扩展点),状态存 metadata。
|
||||
import { definePlugin, useMemo, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
|
||||
const EDITOR_FONT = 12;
|
||||
const EDITOR_LINE = 20;
|
||||
|
||||
function HtmlEditor({ ctx, value }: { ctx: CanvasNodeContentProps["ctx"]; value: string }) {
|
||||
const gutterRef = useRef<HTMLDivElement>(null);
|
||||
// 行数:按换行统计,至少 1 行;value 变化时重算
|
||||
const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
const codeStyle = { fontFamily: "monospace", fontSize: EDITOR_FONT, lineHeight: `${EDITOR_LINE}px`, boxSizing: "border-box" } as const;
|
||||
|
||||
return (
|
||||
<div data-canvas-no-zoom style={{ height: "100%", width: "100%", display: "flex", overflow: "hidden", borderRadius: 16, background: ctx.theme.node.fill }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
{/* 行号槽:跟随文本域滚动,不可单独滚动 */}
|
||||
<div
|
||||
ref={gutterRef}
|
||||
aria-hidden
|
||||
style={{
|
||||
...codeStyle,
|
||||
flex: "0 0 auto",
|
||||
padding: "16px 8px 16px 12px",
|
||||
textAlign: "right",
|
||||
color: ctx.theme.node.placeholder,
|
||||
background: `${ctx.theme.toolbar.panel}66`,
|
||||
borderRight: `1px solid ${ctx.theme.node.stroke}`,
|
||||
overflow: "hidden",
|
||||
userSelect: "none",
|
||||
whiteSpace: "pre",
|
||||
}}
|
||||
>
|
||||
{/* 用负 margin 让整列跟随 scrollTop 平移,和 textarea 同步 */}
|
||||
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="<div>Hello, {{input}}</div>"
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ ...codeStyle, flex: "1 1 auto", minWidth: 0, height: "100%", resize: "none", background: "transparent", padding: "16px 16px 16px 12px", outline: "none", border: "none", color: ctx.theme.node.text, whiteSpace: "pre", overflow: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||
const value = ctx.node.metadata?.content || "";
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
const upstreamText = useMemo(
|
||||
() =>
|
||||
ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
[ctx],
|
||||
);
|
||||
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
|
||||
|
||||
if (editing) {
|
||||
return <HtmlEditor ctx={ctx} value={value} />;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
|
||||
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
||||
<span style={{ fontSize: 13 }}>选中节点,点上方工具条的 ✎ 编辑 HTML</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 预览态:iframe 的鼠标交互由宿主「交互 ⇄ 移动」开关统一控制(见 interactionToggle),
|
||||
// 这里无需再手动做 pointer-events 穿透。data-canvas-no-zoom 保证交互时滚动作用于页面而非缩放画布。
|
||||
return (
|
||||
<div data-canvas-no-zoom style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||
<iframe
|
||||
title="html-preview"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
srcDoc={html}
|
||||
style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "html",
|
||||
name: "HTML 节点",
|
||||
version: "1.2.0",
|
||||
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||
nodes: [
|
||||
{
|
||||
type: "html:render",
|
||||
title: "HTML",
|
||||
icon: "🌐",
|
||||
description: "沙箱渲染 HTML",
|
||||
defaultSize: { width: 420, height: 320 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#ec4899",
|
||||
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互(编辑器始终可操作)并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
Content: HtmlContent,
|
||||
// 仅保留「编辑/预览」开关;交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "html-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 HTML 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Markdown 节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:在画布里编辑与渲染 Markdown。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/markdown.js,并同步到 web/public/plugins/markdown.js
|
||||
npm run dev # watch,改动自动构建
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/markdown.js`(或托管后的公网 URL)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
`npm run dev` 起 watch,在 `web/.env.local` 加 `VITE_DEV_PLUGINS=/plugins/markdown.js`,起画布后改 `src/index.jsx` 刷新页面即生效,无需反复安装。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
|
||||
> 本插件演示了独立 CSS 文件用法:`src/styles.css` 经 esbuild `text` loader 打进 bundle,通过插件 `css` 字段自动注入/清理。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas Markdown 节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
// esbuild 以 text loader 把 .css 打成字符串
|
||||
declare module "*.css" {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
|
||||
// 重依赖从 CDN 动态加载,不打进 bundle;此处声明以便 TS 提示
|
||||
declare module "https://esm.sh/marked@14" {
|
||||
export const marked: { parse: (src: string) => string };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
|
||||
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
|
||||
// 切「交互」态可滚动/选择;编辑态强制可交互。
|
||||
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
|
||||
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
|
||||
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
import css from "./styles.css";
|
||||
|
||||
type Marked = { parse: (src: string) => string };
|
||||
|
||||
let marked: Marked | undefined; // 加载完成后的 marked 实例,模块级缓存,后续同步渲染
|
||||
let markedPromise: Promise<Marked> | undefined;
|
||||
function loadMarked(): Promise<Marked> {
|
||||
if (marked) return Promise.resolve(marked);
|
||||
if (!markedPromise) markedPromise = import("https://esm.sh/marked@14").then((mod: { marked: Marked }) => (marked = mod.marked));
|
||||
return markedPromise;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = "*选中节点,点上方工具条的 ✎ 编辑 Markdown*";
|
||||
|
||||
// 解析结果按源码缓存(模块级):同一段 Markdown 只解析一次,重复渲染/重挂载都命中缓存,
|
||||
// 返回的是同一个字符串引用,配合下方「值不变不写 DOM」彻底避免图片被重新请求。
|
||||
const htmlCache = new Map<string, string>();
|
||||
function renderMarkdown(source: string): string {
|
||||
if (!marked) return "";
|
||||
const key = source || PLACEHOLDER;
|
||||
let out = htmlCache.get(key);
|
||||
if (out === undefined) {
|
||||
out = marked.parse(key);
|
||||
htmlCache.set(key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 预览态:用 ref 手动写 innerHTML,且仅在 html 真正变化时写入。
|
||||
// 这样宿主的任何重渲染(点击、移动视角、选中态变化等)都不会触碰已渲染的 DOM,图片不会重新加载。
|
||||
function MarkdownPreview({ ctx }: CanvasNodeContentProps) {
|
||||
const [, force] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const lastHtml = useRef<string | null>(null);
|
||||
|
||||
// marked 未就绪时按需加载,加载完触发一次重渲染填充内容
|
||||
useEffect(() => {
|
||||
if (marked) return;
|
||||
let alive = true;
|
||||
loadMarked().then(() => alive && force((n) => n + 1));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const source = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
const html = renderMarkdown(source);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || lastHtml.current === html) return; // 内容没变:不动 DOM,已加载的图片保持原样
|
||||
el.innerHTML = html;
|
||||
lastHtml.current = html;
|
||||
}, [html]);
|
||||
|
||||
return <div ref={ref} className="cnv-md" data-canvas-no-zoom onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", color: ctx.theme.node.text }} />;
|
||||
}
|
||||
|
||||
function MarkdownEditor({ ctx }: CanvasNodeContentProps) {
|
||||
const value = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
return (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="# 输入 Markdown"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, boxSizing: "border-box", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
|
||||
return ctx.node.metadata?.editing ? <MarkdownEditor ctx={ctx} /> : <MarkdownPreview ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "markdown",
|
||||
name: "Markdown 节点",
|
||||
version: "1.1.0",
|
||||
description: "在画布中编辑与渲染 Markdown",
|
||||
css,
|
||||
nodes: [
|
||||
{
|
||||
type: "markdown:doc",
|
||||
title: "Markdown",
|
||||
icon: "📝",
|
||||
description: "编辑与渲染 Markdown",
|
||||
defaultSize: { width: 360, height: 300 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#6366f1",
|
||||
hidePanel: true, // 纯展示/编辑节点:不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||
Content: MarkdownContent,
|
||||
// 仅保留「编辑/预览」开关(状态存 metadata.editing);交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "md-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 Markdown 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
.cnv-md {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cnv-md h1,
|
||||
.cnv-md h2,
|
||||
.cnv-md h3 {
|
||||
margin: 0.6em 0 0.3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cnv-md h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.cnv-md h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.cnv-md p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.cnv-md a {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cnv-md code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: rgba(120, 120, 120, 0.16);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.cnv-md pre {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(120, 120, 120, 0.14);
|
||||
overflow: auto;
|
||||
}
|
||||
.cnv-md pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.cnv-md ul,
|
||||
.cnv-md ol {
|
||||
padding-left: 1.4em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.cnv-md blockquote {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0.8em;
|
||||
border-left: 3px solid rgba(120, 120, 120, 0.4);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cnv-md img {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 3D 全景节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:查看 360° 等距柱状(equirectangular)全景图,可拖拽旋转。可从上游图片节点自动取图。three.js 运行时从 CDN 按需加载,不打进插件体积。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/panorama.js,并同步到 web/public/plugins/panorama.js
|
||||
npm run dev # watch
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/panorama.js`(或托管后的公网 URL)。连接一个全景图片节点到本节点即可查看。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 3D 全景节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
// three 从 CDN 动态加载,不打进 bundle;声明为宽松类型以便通过 tsc
|
||||
declare module "https://esm.sh/three@0.180.0";
|
||||
@@ -0,0 +1,311 @@
|
||||
// 3D 全景节点:等距柱状(equirectangular)360° 全景查看器。
|
||||
// 三种取图方式:① 从上游图片节点自动取图 ② 本地上传 ③ AI 生成(复用宿主内置生成面板)。
|
||||
// three.js 从 CDN 按需加载,不打进 bundle。AI 生成通过 useBuiltinPanel 声明,结果写回本节点。
|
||||
import { definePlugin, useCallback, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps, CanvasNodeContext } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
|
||||
let threePromise: Promise<any> | undefined;
|
||||
function loadThree(): Promise<any> {
|
||||
if (!threePromise) threePromise = import("https://esm.sh/three@0.180.0");
|
||||
return threePromise;
|
||||
}
|
||||
|
||||
// AI 生成的固定前置提示词:约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||
// 用户只需描述场景内容,这段会自动拼在前面,保证 2:1、无缝、无畸变文字等。
|
||||
const PANORAMA_SYSTEM_PROMPT =
|
||||
"A seamless 360-degree equirectangular panorama, 2:1 aspect ratio, full spherical VR photo, " +
|
||||
"horizontally wrapping seamlessly at the left and right edges, no visible seam, no distortion artifacts, " +
|
||||
"even horizon, no text, no watermark. Scene: ";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全景查看器:球体内壁贴等距柱状纹理,指针拖动转视角、滚轮缩放 fov。
|
||||
// 自适应容器尺寸(ResizeObserver);超过 GPU 上限的大图先降采样,避免加载失败。
|
||||
// ---------------------------------------------------------------------------
|
||||
function PanoramaViewer({ src, ctx }: { src: string; ctx: CanvasNodeContext }) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount || !src) return;
|
||||
let disposed = false;
|
||||
let frame = 0;
|
||||
let renderer: any = null;
|
||||
let geometry: any = null;
|
||||
let material: any = null;
|
||||
let texture: any = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let cleanupEvents = () => {};
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
loadThree()
|
||||
.then((THREE: any) => {
|
||||
if (disposed || !mountRef.current) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
|
||||
const target = new THREE.Vector3();
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
const canvas: HTMLCanvasElement = renderer.domElement;
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
canvas.style.display = "block";
|
||||
canvas.style.cursor = "grab";
|
||||
mount.appendChild(canvas);
|
||||
const maxTextureSide = Math.min(renderer.capabilities.maxTextureSize || 4096, 4096);
|
||||
|
||||
geometry = new THREE.SphereGeometry(500, 96, 64);
|
||||
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
|
||||
material = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
scene.add(new THREE.Mesh(geometry, material));
|
||||
|
||||
// 视角状态
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
let fov = 70;
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startLon = 0;
|
||||
let startLat = 0;
|
||||
|
||||
const onDown = (event: PointerEvent) => {
|
||||
dragging = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
startLon = lon;
|
||||
startLat = lat;
|
||||
canvas.style.cursor = "grabbing";
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
lon = startLon - (event.clientX - startX) * 0.12;
|
||||
lat = Math.max(-84, Math.min(84, startLat + (event.clientY - startY) * 0.12));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragging = false;
|
||||
canvas.style.cursor = "grab";
|
||||
};
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
fov = Math.max(38, Math.min(86, fov + event.deltaY * 0.035));
|
||||
};
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
cleanupEvents = () => {
|
||||
canvas.removeEventListener("pointerdown", onDown);
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
canvas.removeEventListener("wheel", onWheel);
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
if (!renderer || !mountRef.current) return;
|
||||
const width = Math.max(1, mountRef.current.clientWidth);
|
||||
const height = Math.max(1, mountRef.current.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(mount);
|
||||
resize();
|
||||
|
||||
const render = () => {
|
||||
if (disposed || !renderer) return;
|
||||
if (!dragging) lon += 0.02; // 缓慢自转
|
||||
camera.fov = fov;
|
||||
camera.updateProjectionMatrix();
|
||||
const phi = THREE.MathUtils.degToRad(90 - lat);
|
||||
const theta = THREE.MathUtils.degToRad(lon);
|
||||
target.set(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
||||
camera.lookAt(target);
|
||||
renderer.render(scene, camera);
|
||||
frame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
|
||||
// 载入纹理:大图降采样到 GPU 上限,避免超限导致黑屏/失败
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
if (disposed || !material) return;
|
||||
try {
|
||||
const w = image.naturalWidth || image.width;
|
||||
const h = image.naturalHeight || image.height;
|
||||
const scale = Math.min(1, maxTextureSide / w, maxTextureSide / h);
|
||||
let source: HTMLImageElement | HTMLCanvasElement = image;
|
||||
if (scale < 1) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.max(1, Math.round(w * scale));
|
||||
c.height = Math.max(1, Math.round(h * scale));
|
||||
c.getContext("2d")?.drawImage(image, 0, 0, c.width, c.height);
|
||||
source = c;
|
||||
}
|
||||
texture = source instanceof HTMLCanvasElement ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
image.onerror = () => !disposed && setStatus("error");
|
||||
image.src = src;
|
||||
})
|
||||
.catch(() => !disposed && setStatus("error"));
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanupEvents();
|
||||
resizeObserver?.disconnect();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
texture?.dispose?.();
|
||||
material?.dispose?.();
|
||||
geometry?.dispose?.();
|
||||
if (renderer) {
|
||||
renderer.domElement?.remove?.();
|
||||
renderer.dispose?.();
|
||||
renderer.forceContextLoss?.();
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }}>
|
||||
<div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} />
|
||||
{status !== "ready" ? (
|
||||
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", color: ctx.theme.node.placeholder, fontSize: 13, background: status === "error" ? "rgba(0,0,0,0.6)" : `linear-gradient(135deg, ${ctx.theme.node.fill}, #000)` }}>
|
||||
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。
|
||||
// 整块不可点,避免误触;拖动节点照常。
|
||||
// ---------------------------------------------------------------------------
|
||||
function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 本地上传:读为 dataURL 写入自身 metadata.content
|
||||
const onPick = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => ctx.updateMetadata({ content: String(reader.result || "") });
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[ctx],
|
||||
);
|
||||
|
||||
const t = ctx.theme;
|
||||
const baseBtn = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
cursor: "pointer",
|
||||
borderRadius: 999,
|
||||
padding: "9px 18px",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${t.node.stroke}`,
|
||||
background: t.node.fill,
|
||||
color: t.node.text,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 20, borderRadius: 16, boxSizing: "border-box", background: t.node.fill }}>
|
||||
<span style={{ fontSize: 34 }}>🌐</span>
|
||||
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => onPick(e.target.files?.[0])} />
|
||||
<button type="button" style={baseBtn} onClick={() => fileRef.current?.click()}>
|
||||
🖼 上传全景图
|
||||
</button>
|
||||
<button type="button" style={{ ...baseBtn, border: "none", background: t.toolbar.activeBg, color: t.toolbar.activeText }} onClick={() => ctx.openPanel()}>
|
||||
✨ AI 生成
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: t.node.placeholder, textAlign: "center" }}>支持 2:1 等距柱状全景图</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
|
||||
const upstreamImage = ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.find((content): content is string => typeof content === "string" && Boolean(content));
|
||||
const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
|
||||
|
||||
if (!source) return <PanoramaEmpty ctx={ctx} />;
|
||||
return <PanoramaViewer src={source} ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "panorama",
|
||||
name: "3D 全景节点",
|
||||
version: "1.1.0",
|
||||
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
|
||||
nodes: [
|
||||
{
|
||||
type: "panorama:viewer",
|
||||
title: "3D 全景",
|
||||
icon: "🌐",
|
||||
description: "360° 全景查看器(上传 / AI 生成)",
|
||||
defaultSize: { width: 480, height: 300 },
|
||||
defaultMetadata: {},
|
||||
minimapColor: "#0ea5e9",
|
||||
// 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
|
||||
interactionToggle: true,
|
||||
// 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
|
||||
// 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||
useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
|
||||
// 作为上游被消费时,输出自身全景图,供下游节点引用
|
||||
resource: (node) => {
|
||||
const content = node.metadata?.content;
|
||||
return typeof content === "string" && content ? { kind: "image", url: content } : null;
|
||||
},
|
||||
Content: PanoramaContent,
|
||||
// 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
|
||||
toolbar: (ctx) => {
|
||||
const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
|
||||
return [
|
||||
{
|
||||
id: "panorama-generate",
|
||||
title: "用 AI 生成全景图(打开下方面板)",
|
||||
label: "AI 生成",
|
||||
icon: "✨",
|
||||
onClick: () => ctx.openPanel(),
|
||||
},
|
||||
...(hasOwnContent
|
||||
? [
|
||||
{
|
||||
id: "panorama-reset",
|
||||
title: "清空当前全景图,重新上传",
|
||||
label: "换图",
|
||||
icon: "🔄",
|
||||
onClick: () => ctx.updateMetadata({ content: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# 官方插件注册表(集中构建 + 远程发布)
|
||||
|
||||
本目录**只放构建脚本**,不放构建产物。官方插件由 CI 构建后发布到孤儿分支 `plugins-dist`,画布经 jsDelivr 从该分支远程拉取并一键安装。第三方插件不进本流程,由用户自行填 JS URL 安装。
|
||||
|
||||
```
|
||||
registry/
|
||||
package.json # 构建依赖(esbuild + SDK)
|
||||
build.mjs # 一次进程构建所有官方插件 → dist/(gitignore)+ 生成清单
|
||||
dist/ # 构建产物(gitignore,不提交;CI 发布到 plugins-dist 分支)
|
||||
```
|
||||
|
||||
**产物不进 git**:`dist/` 与 `node_modules/` 均被 `.gitignore` 覆盖。`main` 分支只有源码与脚本。
|
||||
|
||||
## 发布流程(CI 自动)
|
||||
|
||||
`.github/workflows/publish-plugins.yml` 在**打版本 tag(`v*`)**或手动触发(`workflow_dispatch`)时,与 GitHub Pages 发布一起跑:
|
||||
|
||||
1. `npm install && npm run build` → 在 `dist/` 产出各 `<id>.js` 与 `official-plugins.json`;
|
||||
2. 把 `dist/` 强推到孤儿分支 **`plugins-dist`**(仅含产物,force-push 覆盖)。
|
||||
|
||||
前端默认从下面地址读取(可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖):
|
||||
|
||||
```
|
||||
https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json
|
||||
```
|
||||
|
||||
清单里每条的 `entry`(相对文件名)由前端解析成与清单同目录的绝对 URL,再走既有 URL 安装流程。jsDelivr 对分支有缓存(约数小时),需要立即生效可对该分支目录做 purge。
|
||||
|
||||
## 新增 / 更新官方插件
|
||||
|
||||
- 改完某官方插件源码后,在 `build.mjs` 的 `OFFICIAL` 里保持登记(新增插件在此加一条),提交到 `main`;
|
||||
- 下次打版本 tag(或手动 `workflow_dispatch`)时,CI 自动重新构建并发布到 `plugins-dist`。
|
||||
|
||||
## 本地自测官方面板
|
||||
|
||||
```bash
|
||||
cd plugins/canvas/registry && npm install && npm run build # 产出 dist/
|
||||
# 用任意静态服务器伺服 dist/,把 VITE_PLUGIN_REGISTRY_URL 指向其 official-plugins.json
|
||||
```
|
||||
@@ -0,0 +1,69 @@
|
||||
// 官方插件集中构建:一次进程构建所有官方插件 + 生成清单,产物进 dist/(已 gitignore)。
|
||||
// 官方插件目录本身不各自安装依赖:统一从本目录 node_modules 解析 SDK(nodePaths)。
|
||||
// CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。
|
||||
// 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。
|
||||
import { build } from "esbuild";
|
||||
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = dirname(fileURLToPath(import.meta.url));
|
||||
const outDir = join(root, "dist");
|
||||
const nodeModules = join(root, "node_modules");
|
||||
|
||||
// 官方插件登记表:新增官方插件在此登记即可。
|
||||
// 版本不在这里写死 —— 从各插件的 package.json 读取(单一真源),
|
||||
// 与插件 src 里 definePlugin({version}) 保持一致,避免清单与产物版本脱节。
|
||||
const OFFICIAL = [
|
||||
{ id: "markdown", dir: "markdown", name: "Markdown 节点", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
|
||||
{ id: "svg", dir: "svg", name: "SVG 节点", description: "透明背景渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
|
||||
{ id: "html", dir: "html", name: "HTML 节点", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
|
||||
{ id: "panorama", dir: "panorama", name: "3D 全景节点", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
|
||||
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", description: "可自选颜色、双击编辑、拖动即可移动的便利贴", icon: "📌" },
|
||||
];
|
||||
|
||||
// 读取插件 package.json 的 version 作为清单版本的唯一来源
|
||||
async function readPluginVersion(dir) {
|
||||
const pkg = JSON.parse(await readFile(join(root, "..", dir, "package.json"), "utf8"));
|
||||
if (!pkg.version) throw new Error(`插件 ${dir} 的 package.json 缺少 version`);
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
for (const plugin of OFFICIAL) {
|
||||
await build({
|
||||
entryPoints: [join(root, "..", plugin.dir, "src", "index.tsx")],
|
||||
outfile: join(outDir, `${plugin.id}.js`),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2020",
|
||||
// automatic JSX → SDK 的 jsx-runtime(内部用宿主 React),react external
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "@infinite-canvas/plugin-sdk",
|
||||
loader: { ".ts": "ts", ".tsx": "tsx", ".css": "text" },
|
||||
external: ["react", "react-dom"],
|
||||
minify: true,
|
||||
nodePaths: [nodeModules],
|
||||
});
|
||||
console.log(`built ${plugin.id}.js`);
|
||||
}
|
||||
|
||||
const manifest = {
|
||||
// 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL
|
||||
version: 1,
|
||||
plugins: await Promise.all(
|
||||
OFFICIAL.map(async (plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
version: await readPluginVersion(plugin.dir),
|
||||
description: plugin.description,
|
||||
icon: plugin.icon,
|
||||
entry: `${plugin.id}.js`,
|
||||
})),
|
||||
),
|
||||
};
|
||||
await writeFile(join(outDir, "official-plugins.json"), JSON.stringify(manifest, null, 4) + "\n");
|
||||
console.log(`wrote official-plugins.json (${OFFICIAL.length} plugins) → dist/`);
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
{
|
||||
"name": "canvas-official-plugins",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-official-plugins",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "canvas-official-plugins",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 官方插件的集中构建(CI 发布到 plugins-dist 分支)",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"esbuild": "^0.25.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
# @infinite-canvas/plugin-sdk
|
||||
|
||||
Infinite Canvas 画布节点插件的 **TypeScript SDK**。插件作者只写节点 UI 与逻辑,类型、JSX、运行时桥接、构建全部由 SDK 提供;产物仍是宿主加载器现有契约的 ESM(React external,宿主单例)。
|
||||
|
||||
## 提供什么
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| **完整类型** | `CanvasPlugin` / `CanvasNodeDefinition` / `CanvasNodeContext` / `CanvasAgentOp` / `CanvasTheme` / `CanvasNodeData` … 全部有提示 |
|
||||
| `definePlugin(...)` | 给插件对象(或工厂)补全类型;对象形式无需再 `const { React } = runtime` |
|
||||
| automatic JSX | `jsxImportSource` 指向本包,TSX 自动转发到宿主 React,**不打包第二份 React** |
|
||||
| 类型化 hooks | `import { useState, useEffect, useMemo, useRef, ... }`,运行时转发宿主 React |
|
||||
| `buildPlugin(...)` | 统一 esbuild 构建,插件 `build.mjs` 只需一行 |
|
||||
|
||||
## 最小插件
|
||||
|
||||
```tsx
|
||||
// src/index.tsx
|
||||
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
function Content({ ctx }: CanvasNodeContentProps) {
|
||||
const [n, setN] = useState(0);
|
||||
return (
|
||||
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||
{ctx.node.title}: {n}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "my-plugin",
|
||||
name: "我的插件",
|
||||
version: "1.0.0",
|
||||
nodes: [
|
||||
{
|
||||
type: "my-plugin:node",
|
||||
title: "示例",
|
||||
icon: "✨",
|
||||
defaultSize: { width: 240, height: 160 },
|
||||
Content,
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
```js
|
||||
// build.mjs
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
await buildPlugin(import.meta.url);
|
||||
```
|
||||
|
||||
`npm run build` 产出 `dist/<目录名>.js` 并同步到 `web/public/plugins/`。
|
||||
|
||||
## 依赖接入
|
||||
|
||||
插件 `package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "module",
|
||||
"scripts": { "build": "node build.mjs", "dev": "node build.mjs --watch", "typecheck": "tsc --noEmit" },
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
插件 `tsconfig.json` 关键项:`"jsx": "react-jsx"`、`"jsxImportSource": "@infinite-canvas/plugin-sdk"`、`"moduleResolution": "bundler"`。
|
||||
|
||||
## 设计约束
|
||||
|
||||
- **React 单例**:JSX 与 hooks 惰性读取 `globalThis.InfiniteCanvasRuntime.React`(宿主在加载插件前注入),react 全程 external,绝不打包第二份。
|
||||
- **重依赖**:three、marked 等在源码里 `await import("https://esm.sh/...")` 动态加载,esbuild 自动 external,不进 bundle。
|
||||
- **类型真源**:`src/types.ts` 是宿主 `web/src/types/canvas-plugin.ts` 公开契约的镜像;宿主契约变更时同步此处。
|
||||
@@ -0,0 +1,75 @@
|
||||
// 共享构建助手:插件的 build.mjs 只需一行 `buildPlugin(import.meta.url)`。
|
||||
// 统一 esbuild 配置(automatic JSX 指向本 SDK、react external、TS/TSX/CSS loader、
|
||||
// 产物同步到 web/public/plugins),消除各插件重复的构建脚本。
|
||||
|
||||
import { build, context } from "esbuild";
|
||||
import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* @param {string} metaUrl 插件 build.mjs 的 import.meta.url
|
||||
* @param {{ name?: string, entry?: string, publicDir?: string, esbuild?: object, plugins?: import("esbuild").Plugin[] }} [overrides]
|
||||
*/
|
||||
export async function buildPlugin(metaUrl, overrides = {}) {
|
||||
const root = dirname(fileURLToPath(metaUrl));
|
||||
const name = overrides.name ?? basename(root); // 目录名即产物名,如 markdown → markdown.js
|
||||
const distDir = join(root, "dist");
|
||||
// 默认同步到仓库内 web/public/plugins(plugins/canvas/<name> → 上溯三层到仓库根)
|
||||
const publicDir = overrides.publicDir ?? join(root, "..", "..", "..", "web", "public", "plugins");
|
||||
const watch = process.argv.includes("--watch");
|
||||
const entry = overrides.entry ?? join(root, "src", "index.tsx");
|
||||
|
||||
const syncToPublic = {
|
||||
name: "sync-to-public",
|
||||
setup(builder) {
|
||||
builder.onEnd(async (result) => {
|
||||
if (result.errors.length) return;
|
||||
await mkdir(publicDir, { recursive: true });
|
||||
await cp(join(distDir, `${name}.js`), join(publicDir, `${name}.js`));
|
||||
// 维护本地插件清单 index.json,供画布启动时自动发现(该目录已 gitignore,仅本地开发用)
|
||||
const indexPath = join(publicDir, "index.json");
|
||||
let list = [];
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(indexPath, "utf8"));
|
||||
if (Array.isArray(parsed)) list = parsed;
|
||||
} catch {
|
||||
// 无清单则新建
|
||||
}
|
||||
const entry = `/plugins/${name}.js`;
|
||||
if (!list.includes(entry)) {
|
||||
list.push(entry);
|
||||
await writeFile(indexPath, JSON.stringify(list, null, 2) + "\n");
|
||||
}
|
||||
console.log(`[${name}] synced → web/public/plugins/${name}.js`);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const options = {
|
||||
entryPoints: [entry],
|
||||
outfile: join(distDir, `${name}.js`),
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "browser",
|
||||
target: "es2020",
|
||||
// automatic JSX → 转发到本 SDK 的 jsx-runtime(内部用宿主 React),插件无需自带 React
|
||||
jsx: "automatic",
|
||||
jsxImportSource: "@infinite-canvas/plugin-sdk",
|
||||
loader: { ".js": "jsx", ".jsx": "jsx", ".ts": "ts", ".tsx": "tsx", ".css": "text" },
|
||||
// 宿主提供单例 React,插件不打包 react;https:// 依赖 esbuild 自动 external
|
||||
external: ["react", "react-dom"],
|
||||
minify: !watch,
|
||||
plugins: [syncToPublic, ...(overrides.plugins ?? [])],
|
||||
...overrides.esbuild,
|
||||
};
|
||||
|
||||
if (watch) {
|
||||
const ctx = await context(options);
|
||||
await ctx.watch();
|
||||
console.log(`[${name}] watching src/ ...`);
|
||||
} else {
|
||||
await build(options);
|
||||
console.log(`[${name}] built → dist/${name}.js`);
|
||||
}
|
||||
}
|
||||
Generated
+535
@@ -0,0 +1,535 @@
|
||||
{
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
|
||||
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
|
||||
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
|
||||
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
|
||||
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
|
||||
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
|
||||
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
|
||||
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
|
||||
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
|
||||
"integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
"integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.25.12",
|
||||
"@esbuild/android-arm": "0.25.12",
|
||||
"@esbuild/android-arm64": "0.25.12",
|
||||
"@esbuild/android-x64": "0.25.12",
|
||||
"@esbuild/darwin-arm64": "0.25.12",
|
||||
"@esbuild/darwin-x64": "0.25.12",
|
||||
"@esbuild/freebsd-arm64": "0.25.12",
|
||||
"@esbuild/freebsd-x64": "0.25.12",
|
||||
"@esbuild/linux-arm": "0.25.12",
|
||||
"@esbuild/linux-arm64": "0.25.12",
|
||||
"@esbuild/linux-ia32": "0.25.12",
|
||||
"@esbuild/linux-loong64": "0.25.12",
|
||||
"@esbuild/linux-mips64el": "0.25.12",
|
||||
"@esbuild/linux-ppc64": "0.25.12",
|
||||
"@esbuild/linux-riscv64": "0.25.12",
|
||||
"@esbuild/linux-s390x": "0.25.12",
|
||||
"@esbuild/linux-x64": "0.25.12",
|
||||
"@esbuild/netbsd-arm64": "0.25.12",
|
||||
"@esbuild/netbsd-x64": "0.25.12",
|
||||
"@esbuild/openbsd-arm64": "0.25.12",
|
||||
"@esbuild/openbsd-x64": "0.25.12",
|
||||
"@esbuild/openharmony-arm64": "0.25.12",
|
||||
"@esbuild/sunos-x64": "0.25.12",
|
||||
"@esbuild/win32-arm64": "0.25.12",
|
||||
"@esbuild/win32-ia32": "0.25.12",
|
||||
"@esbuild/win32-x64": "0.25.12"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 画布节点插件 SDK:类型、automatic JSX 运行时、构建助手",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./jsx-runtime": "./src/jsx-runtime.ts",
|
||||
"./jsx-dev-runtime": "./src/jsx-dev-runtime.ts",
|
||||
"./build": "./build.mjs"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { CanvasPlugin, CanvasPluginFactory, PluginRuntime } from "./types";
|
||||
|
||||
// 身份帮助函数:为插件对象(或工厂)补全类型,给作者完整补全与校验。
|
||||
// 采用 automatic JSX 后,插件通常不再需要 runtime,直接写对象形式即可:
|
||||
//
|
||||
// export default definePlugin({ id, name, version, nodes: [...] })
|
||||
//
|
||||
// 仍支持工厂形式(需要 runtime.version/emit/on 时):
|
||||
//
|
||||
// export default definePlugin((runtime) => ({ ... }))
|
||||
|
||||
export function definePlugin(plugin: CanvasPlugin): CanvasPlugin;
|
||||
export function definePlugin(factory: CanvasPluginFactory): CanvasPluginFactory;
|
||||
export function definePlugin(input: CanvasPlugin | CanvasPluginFactory): CanvasPlugin | CanvasPluginFactory {
|
||||
return input;
|
||||
}
|
||||
|
||||
export type { PluginRuntime };
|
||||
@@ -0,0 +1,8 @@
|
||||
// @infinite-canvas/plugin-sdk 公开入口。
|
||||
//
|
||||
// 插件作者用 TS/TSX 编写,只关注节点 UI 与逻辑;类型、JSX、运行时桥接、构建
|
||||
// 全部由本 SDK 提供,产物仍是宿主 loader 现有契约的 ESM(React external,宿主单例)。
|
||||
|
||||
export * from "./types";
|
||||
export { definePlugin } from "./define-plugin";
|
||||
export { getReact, getRuntime, useState, useEffect, useLayoutEffect, useMemo, useCallback, useRef, useReducer, useContext, useId } from "./runtime";
|
||||
@@ -0,0 +1,16 @@
|
||||
// automatic JSX 的 dev 变体(编译器在 dev 模式会引用 jsxDEV)。转发到同一套 createElement。
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import { getReact } from "./runtime";
|
||||
import { Fragment } from "./jsx-runtime";
|
||||
|
||||
export { Fragment };
|
||||
export type { JSX } from "./jsx-runtime";
|
||||
|
||||
export function jsxDEV(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||
const react = getReact();
|
||||
const resolvedType = type === Fragment ? react.Fragment : type;
|
||||
const config = key === undefined ? props : { ...(props ?? {}), key };
|
||||
return react.createElement(resolvedType as never, config as never);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// automatic JSX 运行时:esbuild/tsc 的 `jsxImportSource` 指向本包时,
|
||||
// TSX 会被编译成对本模块 jsx()/jsxs() 的调用。这里统一转发到宿主 React.createElement,
|
||||
// 让插件写纯 TSX、无需手动 `const { React } = runtime`,同时 react 全程 external。
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import { getReact } from "./runtime";
|
||||
|
||||
// Fragment 哨兵:渲染时才解析为宿主 React.Fragment,避免模块顶层触碰运行时。
|
||||
export const Fragment = Symbol.for("infinite-canvas.jsx.fragment") as unknown as React.ExoticComponent<{ children?: React.ReactNode }>;
|
||||
|
||||
function createElement(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||
const react = getReact();
|
||||
const resolvedType = type === Fragment ? react.Fragment : type;
|
||||
// automatic 运行时已把 children 放进 props;key 单独传入以避免展开 key 警告。
|
||||
const config = key === undefined ? props : { ...(props ?? {}), key };
|
||||
return react.createElement(resolvedType as never, config as never);
|
||||
}
|
||||
|
||||
export function jsx(type: unknown, props: Record<string, unknown> | null, key?: unknown): React.ReactElement {
|
||||
return createElement(type, props, key);
|
||||
}
|
||||
|
||||
// jsxs 用于静态多子节点;转发逻辑与 jsx 一致(children 已在 props 内)。
|
||||
export const jsxs = jsx;
|
||||
|
||||
// 让 `jsxImportSource` 指向本包的编译器能从这里取到 JSX 内建标签类型(复用 @types/react)。
|
||||
export namespace JSX {
|
||||
export type Element = React.JSX.Element;
|
||||
export type ElementType = React.JSX.ElementType;
|
||||
export type ElementClass = React.JSX.ElementClass;
|
||||
export type ElementAttributesProperty = React.JSX.ElementAttributesProperty;
|
||||
export type ElementChildrenAttribute = React.JSX.ElementChildrenAttribute;
|
||||
export type LibraryManagedAttributes<C, P> = React.JSX.LibraryManagedAttributes<C, P>;
|
||||
export type IntrinsicAttributes = React.JSX.IntrinsicAttributes;
|
||||
export type IntrinsicClassAttributes<T> = React.JSX.IntrinsicClassAttributes<T>;
|
||||
export type IntrinsicElements = React.JSX.IntrinsicElements;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// 运行时桥接:所有对宿主 React 的访问都惰性读取全局运行时,
|
||||
// 保证插件与画布共用同一份 React(不打包第二份),且不在模块顶层触碰运行时
|
||||
// (宿主 loader 会先 import 插件模块、再设置运行时)。
|
||||
|
||||
import type * as React from "react";
|
||||
|
||||
import type { PluginRuntime } from "./types";
|
||||
|
||||
type RuntimeGlobal = { InfiniteCanvasRuntime?: PluginRuntime };
|
||||
|
||||
/** 取宿主注入的插件运行时(含 React、事件总线、injectCSS 等)。 */
|
||||
export function getRuntime(): PluginRuntime {
|
||||
const runtime = (globalThis as unknown as RuntimeGlobal).InfiniteCanvasRuntime;
|
||||
if (!runtime) {
|
||||
throw new Error("[plugin-sdk] Infinite Canvas 运行时未就绪:请在画布宿主中加载本插件");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
|
||||
/** 取宿主的 React 实例。仅在渲染/hook 调用时(运行时已就绪)使用。 */
|
||||
export function getReact(): typeof React {
|
||||
return getRuntime().React as unknown as typeof React;
|
||||
}
|
||||
|
||||
// --- 类型完整的 hooks 转发:签名取自 @types/react,运行时转发到宿主 React ---
|
||||
// 这样插件作者可以直接 `import { useState } from "@infinite-canvas/plugin-sdk"`,
|
||||
// 无需再从 runtime 里解构 React。
|
||||
|
||||
export const useState: typeof React.useState = ((...args: unknown[]) => (getReact().useState as (...a: unknown[]) => unknown)(...args)) as typeof React.useState;
|
||||
export const useEffect: typeof React.useEffect = ((...args: unknown[]) => (getReact().useEffect as (...a: unknown[]) => unknown)(...args)) as typeof React.useEffect;
|
||||
export const useLayoutEffect: typeof React.useLayoutEffect = ((...args: unknown[]) => (getReact().useLayoutEffect as (...a: unknown[]) => unknown)(...args)) as typeof React.useLayoutEffect;
|
||||
export const useMemo: typeof React.useMemo = ((...args: unknown[]) => (getReact().useMemo as (...a: unknown[]) => unknown)(...args)) as typeof React.useMemo;
|
||||
export const useCallback: typeof React.useCallback = ((...args: unknown[]) => (getReact().useCallback as (...a: unknown[]) => unknown)(...args)) as typeof React.useCallback;
|
||||
export const useRef: typeof React.useRef = ((...args: unknown[]) => (getReact().useRef as (...a: unknown[]) => unknown)(...args)) as typeof React.useRef;
|
||||
export const useReducer: typeof React.useReducer = ((...args: unknown[]) => (getReact().useReducer as (...a: unknown[]) => unknown)(...args)) as typeof React.useReducer;
|
||||
export const useContext: typeof React.useContext = ((...args: unknown[]) => (getReact().useContext as (...a: unknown[]) => unknown)(...args)) as typeof React.useContext;
|
||||
export const useId: typeof React.useId = ((...args: unknown[]) => (getReact().useId as (...a: unknown[]) => unknown)(...args)) as typeof React.useId;
|
||||
@@ -0,0 +1,331 @@
|
||||
// Infinite Canvas 插件公共契约类型。
|
||||
//
|
||||
// 这是插件作者面向的「公开接口」子集,自包含、不依赖宿主 `@/` 内部模块,
|
||||
// 因此可以被独立构建的插件包直接 import,获得完整的 TS 提示。
|
||||
//
|
||||
// 真源:宿主 `web/src/types/canvas-plugin.ts` 及其引用的类型。本文件是它的公开镜像,
|
||||
// 若宿主契约变更,请同步更新此处(两者结构保持一致即可,无需逐字节相同)。
|
||||
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 画布基础几何与节点数据
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Position = { x: number; y: number };
|
||||
|
||||
export type ViewportTransform = { x: number; y: number; k: number };
|
||||
|
||||
// 内置节点类型;插件节点建议用 "<pluginId>:<name>"。放开为字符串以便扩展。
|
||||
export type CanvasBuiltinNodeType = "image" | "text" | "config" | "video" | "audio" | "group";
|
||||
export type CanvasNodeTypeId = CanvasBuiltinNodeType | (string & {});
|
||||
|
||||
export type CanvasNodeStatus = "idle" | "success" | "loading" | "error";
|
||||
export type CanvasGenerationMode = "text" | "image" | "video" | "audio";
|
||||
export type CanvasImageGenerationType = "generation" | "edit";
|
||||
|
||||
// 节点 metadata 是扁平可选字段袋;插件自定义字段可直接写入(内容惯例放 content)。
|
||||
export type CanvasNodeMetadata = {
|
||||
content?: string;
|
||||
composerContent?: string;
|
||||
prompt?: string;
|
||||
status?: CanvasNodeStatus;
|
||||
errorDetails?: string;
|
||||
fontSize?: number;
|
||||
generationMode?: CanvasGenerationMode;
|
||||
generationType?: CanvasImageGenerationType;
|
||||
model?: string;
|
||||
size?: string;
|
||||
quality?: string;
|
||||
count?: number;
|
||||
seconds?: string;
|
||||
vquality?: string;
|
||||
generateAudio?: string;
|
||||
watermark?: string;
|
||||
audioVoice?: string;
|
||||
audioFormat?: string;
|
||||
audioSpeed?: string;
|
||||
audioInstructions?: string;
|
||||
references?: string[];
|
||||
naturalWidth?: number;
|
||||
naturalHeight?: number;
|
||||
freeResize?: boolean;
|
||||
isBatchRoot?: boolean;
|
||||
batchRootId?: string;
|
||||
batchChildIds?: string[];
|
||||
batchUsesReferenceImages?: boolean;
|
||||
primaryImageId?: string;
|
||||
imageBatchExpanded?: boolean;
|
||||
storageKey?: string;
|
||||
mimeType?: string;
|
||||
bytes?: number;
|
||||
durationMs?: number;
|
||||
groupId?: string;
|
||||
// 插件可写入任意自定义字段
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type CanvasNodeData = {
|
||||
id: string;
|
||||
type: CanvasNodeTypeId;
|
||||
title: string;
|
||||
position: Position;
|
||||
width: number;
|
||||
height: number;
|
||||
metadata?: CanvasNodeMetadata;
|
||||
};
|
||||
|
||||
export type CanvasConnection = {
|
||||
id: string;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 主题 token(用来让插件 UI 跟随画布明暗主题)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasTheme = {
|
||||
canvas: {
|
||||
background: string;
|
||||
dot: string;
|
||||
line: string;
|
||||
selectionStroke: string;
|
||||
selectionFill: string;
|
||||
};
|
||||
node: {
|
||||
label: string;
|
||||
fill: string;
|
||||
panel: string;
|
||||
stroke: string;
|
||||
activeStroke: string;
|
||||
placeholder: string;
|
||||
text: string;
|
||||
muted: string;
|
||||
faint: string;
|
||||
};
|
||||
toolbar: {
|
||||
panel: string;
|
||||
border: string;
|
||||
item: string;
|
||||
itemHover: string;
|
||||
activeBg: string;
|
||||
activeText: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 画布指令集(ctx.applyOps):与 AI Agent 同级的画布操作能力
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasAgentOp =
|
||||
| { type: "add_node"; id?: string; nodeType?: CanvasNodeTypeId; title?: string; position?: { x: number; y: number }; x?: number; y?: number; width?: number; height?: number; metadata?: CanvasNodeMetadata }
|
||||
| { type: "update_node"; id: string; patch?: Partial<CanvasNodeData>; metadata?: CanvasNodeMetadata }
|
||||
| { type: "delete_node"; id?: string; ids?: string[]; nodeType?: CanvasNodeTypeId }
|
||||
| { type: "delete_connections"; id?: string; ids?: string[]; all?: boolean }
|
||||
| { type: "connect_nodes"; id?: string; fromNodeId: string; toNodeId: string }
|
||||
| { type: "set_viewport"; viewport: ViewportTransform }
|
||||
| { type: "select_nodes"; ids: string[] }
|
||||
| { type: "run_generation"; nodeId: string; mode?: CanvasGenerationMode; prompt?: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 资源:插件节点作为上游输入被消费时输出什么(接入生成/引用体系)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasResourceKind = "image" | "video" | "audio" | "text";
|
||||
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 生成:插件直接复用宿主的模型/密钥配置发起生成(生图/生视频/生文本/生音频)
|
||||
//
|
||||
// 插件本身拿不到 API Key 与模型配置,这些能力由宿主注入。前置/系统提示词由
|
||||
// 插件自行拼进 prompt(宿主不感知),因此不同插件可各自定制自己的提示词策略。
|
||||
// 若宿主 AI 配置未就绪,会抛错(并由宿主提示用户去配置),插件用 try/catch 处理即可。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 生成公共可选项;references 为图生图/图生视频的参考图(dataURL 或可访问 URL)
|
||||
export type GenerateOptions = {
|
||||
signal?: AbortSignal;
|
||||
references?: string[];
|
||||
model?: string; // 指定模型(取自 ai.listModels 的 value);缺省用宿主当前配置
|
||||
};
|
||||
|
||||
export type GenerateImageOptions = GenerateOptions & {
|
||||
count?: number; // 期望生成张数(宿主会按模型上限裁剪)
|
||||
size?: string; // 形如 "1024x1024" / "auto";缺省用宿主当前配置
|
||||
};
|
||||
|
||||
export type GenerateImageResult = {
|
||||
// 生成图的 dataURL(已可直接作为 <img src> 或 three 纹理使用)
|
||||
images: string[];
|
||||
};
|
||||
|
||||
export type GenerateVideoOptions = GenerateOptions & {
|
||||
size?: string;
|
||||
seconds?: string;
|
||||
};
|
||||
|
||||
export type GenerateVideoResult = {
|
||||
url: string; // 视频可访问 URL
|
||||
mimeType: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
export type GenerateTextOptions = {
|
||||
signal?: AbortSignal;
|
||||
model?: string;
|
||||
system?: string; // 附加系统提示词(拼在宿主系统提示之后)
|
||||
onDelta?: (text: string) => void; // 流式增量回调
|
||||
};
|
||||
|
||||
export type GenerateTextResult = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
// 一个可选模型:value 传回给 generateXxx({ model }),label 用于展示
|
||||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||
export type ModelOption = { value: string; label: string };
|
||||
|
||||
// 宿主注入的 AI 生成能力,挂在 ctx.ai 下。任何插件均可调用。
|
||||
export type CanvasPluginAi = {
|
||||
generateImage: (prompt: string, options?: GenerateImageOptions) => Promise<GenerateImageResult>;
|
||||
generateVideo: (prompt: string, options?: GenerateVideoOptions) => Promise<GenerateVideoResult>;
|
||||
generateText: (prompt: string, options?: GenerateTextOptions) => Promise<GenerateTextResult>;
|
||||
// 列出某能力下用户已配置的可选模型;不传能力则返回全部
|
||||
listModels: (capability?: ModelCapability) => ModelOption[];
|
||||
// 该能力当前默认选中的模型 value(可作为下拉框初始值)
|
||||
defaultModel: (capability: ModelCapability) => string;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PluginStorage = {
|
||||
get: <T = unknown>(key: string) => Promise<T | null>;
|
||||
set: (key: string, value: unknown) => Promise<void>;
|
||||
remove: (key: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export type CanvasNodeContext = {
|
||||
// 自身数据
|
||||
node: CanvasNodeData;
|
||||
theme: CanvasTheme;
|
||||
scale: number;
|
||||
isSelected: boolean; // 该节点当前是否被选中(用于按需启用 iframe 交互等)
|
||||
updateMetadata: (patch: CanvasNodeMetadata) => void;
|
||||
updateNode: (patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
|
||||
// 图访问
|
||||
getNode: (id: string) => CanvasNodeData | null;
|
||||
getNodes: () => CanvasNodeData[];
|
||||
getConnections: () => CanvasConnection[];
|
||||
getUpstream: () => CanvasNodeData[];
|
||||
getDownstream: () => CanvasNodeData[];
|
||||
// 画布操作(复用 Agent 指令集)
|
||||
applyOps: (ops: CanvasAgentOp[]) => void;
|
||||
// 节点间/插件间通信
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
|
||||
ai: CanvasPluginAi;
|
||||
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
|
||||
openPanel: () => void;
|
||||
closePanel: () => void;
|
||||
// 插件私有持久化,按插件 id 命名空间隔离
|
||||
storage: PluginStorage;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 节点定义:内置节点与插件节点统一走这套结构
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasNodeToolbarItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
export type CanvasNodeContentProps = { ctx: CanvasNodeContext };
|
||||
export type CanvasNodePanelProps = { ctx: CanvasNodeContext; onClose: () => void };
|
||||
|
||||
// 复用宿主内置生成面板(与图片/视频/文本节点同一个组件:模型选择、参数设置、
|
||||
// 提示词库、运行/停止状态全部一致)。声明它即可获得完整生成体验,无需自写面板。
|
||||
export type CanvasBuiltinPanelConfig = {
|
||||
mode: "image" | "video" | "text" | "audio"; // 生成类型,决定面板里的模型/设置项
|
||||
// 提交给模型前自动拼在用户提示词前面的固定前缀(如全景图的 equirectangular 约束)
|
||||
promptPrefix?: string;
|
||||
// true(默认)时生成结果写回本节点自身 metadata.content;false 则按内置逻辑生成到下游新节点
|
||||
writeBackToSelf?: boolean;
|
||||
};
|
||||
|
||||
export type CanvasNodeDefinition = {
|
||||
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||
title: string;
|
||||
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||
description?: string;
|
||||
defaultSize: { width: number; height: number };
|
||||
defaultMetadata?: CanvasNodeMetadata;
|
||||
minimapColor?: string;
|
||||
showInCreateMenu?: boolean; // 默认 true
|
||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
|
||||
// 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图);选中时仍显示选中描边
|
||||
transparentBackground?: boolean;
|
||||
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
|
||||
// 复用宿主内置生成面板;与自定义 Panel 二选一(同时提供时优先 Panel)
|
||||
useBuiltinPanel?: CanvasBuiltinPanelConfig;
|
||||
// 为 true 时:宿主自动在工具条加「交互 ⇄ 移动」开关,并按 metadata.interactive 控制内容层指针事件。
|
||||
// 默认(interactive 未设/为 false)为「移动」态:内容不吃指针,拖动整块移动节点;
|
||||
// 切到「交互」态后内容可点击/拖拽(如全景转视角、iframe 操作)。适合内部有交互的展示型节点。
|
||||
interactionToggle?: boolean;
|
||||
// 配合 interactionToggle:返回 true 表示当前节点内容「强制可交互」(如编辑态),
|
||||
// 此时忽略 interactive 标志、始终允许操作,并隐藏移动/交互开关。缺省视为 false。
|
||||
forceInteractive?: (node: CanvasNodeData) => boolean;
|
||||
keepAspectRatio?: (node: CanvasNodeData) => boolean;
|
||||
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
|
||||
// 渲染
|
||||
Content?: ComponentType<CanvasNodeContentProps>;
|
||||
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板(自定义)
|
||||
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
|
||||
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 插件运行时与插件包
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 插件启动时(setup)可访问的应用能力
|
||||
export type CanvasPluginApp = {
|
||||
version: string;
|
||||
emit: (event: string, payload?: unknown) => void;
|
||||
on: (event: string, handler: (payload: unknown) => void) => () => void;
|
||||
// 注入插件样式,返回移除函数;传 key 时同 key 覆盖旧样式
|
||||
injectCSS: (css: string, key?: string) => () => void;
|
||||
};
|
||||
|
||||
// 宿主注入的运行时(工厂形式插件的入参),内含宿主 React 实例避免双 React
|
||||
export type PluginRuntime = CanvasPluginApp & {
|
||||
React: typeof import("react");
|
||||
jsx: typeof import("react").createElement;
|
||||
Fragment: typeof import("react").Fragment;
|
||||
};
|
||||
|
||||
// 插件包(默认导出对象,或返回它的工厂函数)
|
||||
export type CanvasPlugin = {
|
||||
id: string; // 唯一,kebab-case
|
||||
name: string;
|
||||
version: string;
|
||||
description?: string;
|
||||
minAppVersion?: string;
|
||||
css?: string; // 插件样式,启用时自动注入、卸载/禁用时自动清理
|
||||
nodes: CanvasNodeDefinition[];
|
||||
setup?: (app: CanvasPluginApp) => void | (() => void);
|
||||
};
|
||||
|
||||
export type CanvasPluginFactory = (runtime: PluginRuntime) => CanvasPlugin;
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": ".",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"allowJs": true,
|
||||
"checkJs": false
|
||||
},
|
||||
"include": ["src", "build.mjs"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 便利贴节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:彩色便利贴,可换色、编辑,并演示用 `ctx.applyOps` 衍生一个连到自己的文本节点(节点间交互示例)。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/sticky-note.js,并同步到 web/public/plugins/sticky-note.js
|
||||
npm run dev # watch
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/sticky-note.js`(或托管后的公网 URL)。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-sticky-note",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-sticky-note",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-sticky-note",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 便利贴节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
// 便利贴节点:纯展示便利贴——整块可拖动、双击编辑、右上角自选颜色。
|
||||
// 不再声明 resource(避免宿主在右上角显示「文本N」资源角标),也不再衍生节点。
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// 预设便签色(点选切换),并额外提供自定义取色
|
||||
const PRESET_COLORS = ["#fde68a", "#fca5a5", "#fdba74", "#a7f3d0", "#bfdbfe", "#ddd6fe", "#f9a8d4", "#e7e5e4"];
|
||||
const DEFAULT_COLOR = PRESET_COLORS[0];
|
||||
|
||||
function StickyNoteContent({ ctx }: CanvasNodeContentProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||
// 取色时的本地预览色:仅本组件即时重渲染,避免每次都写宿主 store
|
||||
const [draftColor, setDraftColor] = useState<string | null>(null);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const commitTimerRef = useRef<number | null>(null);
|
||||
|
||||
// pluginColor 是插件自定义 metadata 字段(读出为 unknown,按需断言)
|
||||
const committedColor = (ctx.node.metadata?.pluginColor as string | undefined) || DEFAULT_COLOR;
|
||||
const color = draftColor ?? committedColor;
|
||||
const content = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
|
||||
// 点击便利贴外部时:退出编辑并收起调色板。
|
||||
// 监听 pointerdown 的 capture 阶段:宿主画布在 pointerdown 上 preventDefault(会抑制
|
||||
// 兼容 mousedown 事件),所以必须用 pointerdown;capture 阶段又能先于宿主的 stopPropagation
|
||||
// 触发,避免「按 Esc 能退出、点别处退不出」。
|
||||
useEffect(() => {
|
||||
if (!editing && !paletteOpen) return;
|
||||
const onDocDown = (e: PointerEvent) => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setEditing(false);
|
||||
setPaletteOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("pointerdown", onDocDown, true);
|
||||
return () => document.removeEventListener("pointerdown", onDocDown, true);
|
||||
}, [editing, paletteOpen]);
|
||||
|
||||
const pickColor = (next: string) => ctx.updateMetadata({ pluginColor: next });
|
||||
|
||||
// 连续取色(系统取色器拖动时 onChange 高频触发)会不停调用 updateMetadata,
|
||||
// 而宿主每次都会整表重建 + 全画布重渲染 + 持久化,导致卡顿。
|
||||
// 这里先本地预览(setDraftColor),再节流提交到宿主。
|
||||
const previewColor = (next: string) => {
|
||||
setDraftColor(next);
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = window.setTimeout(() => {
|
||||
commitTimerRef.current = null;
|
||||
pickColor(next);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// 立即提交(点击预设色、取色器关闭时用):清掉待提交的节流并写入。
|
||||
const commitColor = (next: string) => {
|
||||
if (commitTimerRef.current) {
|
||||
clearTimeout(commitTimerRef.current);
|
||||
commitTimerRef.current = null;
|
||||
}
|
||||
setDraftColor(next);
|
||||
pickColor(next);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||
}, []);
|
||||
|
||||
// 宿主里的颜色一旦真正变化(提交完成 / 撤销重做),清掉本地预览,回到 store 为准。
|
||||
// 拖动取色期间 committedColor 不变,draftColor 得以保留,故不影响即时预览。
|
||||
useEffect(() => {
|
||||
setDraftColor(null);
|
||||
}, [committedColor]);
|
||||
|
||||
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
|
||||
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-canvas-no-zoom
|
||||
style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", background: color, borderRadius: 16, padding: 14, boxSizing: "border-box", cursor: editing ? "text" : "move", boxShadow: "inset 0 1px 0 rgba(255,255,255,.45)" }}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{/* 右上角:当前颜色小圆点,点开后自选颜色(预设 + 自定义) */}
|
||||
<div style={{ position: "absolute", top: 8, right: 8, zIndex: 5 }} onMouseDown={stop} onDoubleClick={stop}>
|
||||
<button
|
||||
type="button"
|
||||
title="选择颜色"
|
||||
onClick={() => setPaletteOpen((v) => !v)}
|
||||
style={{ width: 22, height: 22, borderRadius: "50%", border: "2px solid rgba(0,0,0,.25)", background: color, cursor: "pointer", padding: 0, boxShadow: "0 1px 3px rgba(0,0,0,.2)" }}
|
||||
/>
|
||||
{paletteOpen ? (
|
||||
<div style={{ position: "absolute", top: 28, right: 0, display: "grid", gridTemplateColumns: "repeat(4, 20px)", gap: 6, padding: 8, borderRadius: 12, background: "#fff", boxShadow: "0 8px 24px rgba(0,0,0,.18)" }}>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
title={c}
|
||||
onClick={() => {
|
||||
commitColor(c);
|
||||
setPaletteOpen(false);
|
||||
}}
|
||||
style={{ width: 20, height: 20, borderRadius: "50%", border: c === color ? "2px solid #1c1917" : "2px solid rgba(0,0,0,.12)", background: c, cursor: "pointer", padding: 0 }}
|
||||
/>
|
||||
))}
|
||||
{/* 自定义取色:点击彩环唤起系统取色器。拖动时本地预览 + 节流提交,松开(blur)时立即提交 */}
|
||||
<label title="自定义颜色" style={{ position: "relative", width: 20, height: 20, borderRadius: "50%", cursor: "pointer", background: "conic-gradient(red, yellow, lime, aqua, blue, magenta, red)", border: "2px solid rgba(0,0,0,.12)", boxSizing: "border-box" }}>
|
||||
<input type="color" value={color} onChange={(e) => previewColor(e.target.value)} onBlur={(e) => commitColor(e.target.value)} style={{ position: "absolute", inset: 0, opacity: 0, width: "100%", height: "100%", border: "none", padding: 0, cursor: "pointer" }} />
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 内容区:双击进入编辑;非编辑态整块可直接拖动移动节点 */}
|
||||
{editing ? (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={content}
|
||||
placeholder="输入便利贴内容…(点击别处或按 Esc 退出编辑)"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onBlur={() => setEditing(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.stopPropagation();
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onMouseDown={stop}
|
||||
onPointerDown={stop}
|
||||
onWheel={stop}
|
||||
style={{ flex: 1, width: "100%", resize: "none", border: "none", outline: "none", background: "transparent", color: "#1c1917", fontSize: 15, lineHeight: 1.5, fontFamily: "inherit" }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, whiteSpace: "pre-wrap", overflow: "hidden", color: content ? "#1c1917" : "rgba(28,25,23,.45)", fontSize: 15, lineHeight: 1.5, userSelect: "none", paddingRight: 22 }}>{content || "双击编辑便利贴"}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "sticky-note",
|
||||
name: "便利贴节点",
|
||||
version: "1.1.0",
|
||||
description: "可自选颜色、双击编辑、拖动即可移动的便利贴",
|
||||
nodes: [
|
||||
{
|
||||
type: "sticky-note:note",
|
||||
title: "便利贴",
|
||||
icon: "📌",
|
||||
description: "彩色便利贴",
|
||||
defaultSize: { width: 240, height: 200 },
|
||||
defaultMetadata: { content: "", pluginColor: DEFAULT_COLOR },
|
||||
minimapColor: "#f59e0b",
|
||||
// 纯记事节点:不弹出下方生成面板(默认会是「生成图片」的提示词面板)
|
||||
hidePanel: true,
|
||||
Content: StickyNoteContent,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# SVG 节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:编辑与渲染 SVG,无自身内容时自动取上游文本节点里的 SVG 源码。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/svg.js,并同步到 web/public/plugins/svg.js
|
||||
npm run dev # watch
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/svg.js`(或托管后的公网 URL)。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
Generated
+65
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,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 的类型定义。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
+6
-3
@@ -7,6 +7,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",
|
||||
@@ -138,6 +139,8 @@
|
||||
|
||||
"@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="],
|
||||
|
||||
"@codemirror/lang-javascript": ["@codemirror/lang-javascript@6.2.5", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", "@codemirror/lint": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0", "@lezer/javascript": "^1.0.0" } }, "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A=="],
|
||||
|
||||
"@codemirror/lang-json": ["@codemirror/lang-json@6.0.2", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@lezer/json": "^1.0.0" } }, "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ=="],
|
||||
|
||||
"@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="],
|
||||
@@ -278,6 +281,8 @@
|
||||
|
||||
"@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="],
|
||||
|
||||
"@lezer/javascript": ["@lezer/javascript@1.5.4", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", "@lezer/lr": "^1.3.0" } }, "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA=="],
|
||||
|
||||
"@lezer/json": ["@lezer/json@1.0.3", "", { "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ=="],
|
||||
|
||||
"@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="],
|
||||
@@ -1144,7 +1149,7 @@
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.1.1", "https://registry.npmmirror.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz", {}, "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
"internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
@@ -2070,8 +2075,6 @@
|
||||
|
||||
"cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
|
||||
|
||||
"d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
|
||||
|
||||
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
|
||||
|
||||
"express/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 由 nginx 官方镜像的入口在启动前自动执行(/docker-entrypoint.d/*.sh),随后 nginx 正常拉起。
|
||||
# 从环境变量生成运行期配置 config.js;每家统计一个独立变量,未设置的留空,
|
||||
# 前端据此判定该家「关闭」,不加载对应脚本、不发外部请求。可同时启用多家。
|
||||
|
||||
# GA4 / 百度 ID 只含字母、数字和连字符;过滤掉其它字符,
|
||||
# 避免值里的引号等破坏 config.js 的 JS 字符串(纵深防御)。
|
||||
sanitize_id() {
|
||||
printf '%s' "$1" | tr -cd 'A-Za-z0-9-'
|
||||
}
|
||||
|
||||
GA4_ID=$(sanitize_id "${ANALYTICS_GA4_ID:-}")
|
||||
BAIDU_ID=$(sanitize_id "${ANALYTICS_BAIDU_ID:-}")
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
ANALYTICS_GA4_ID: "${GA4_ID}",
|
||||
ANALYTICS_BAIDU_ID: "${BAIDU_ID}"
|
||||
};
|
||||
EOF
|
||||
@@ -17,6 +17,7 @@
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -14,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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
|
||||
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
@@ -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 && (
|
||||
|
||||
@@ -48,14 +48,25 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
|
||||
return (
|
||||
<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={isUser ? "min-w-0 max-w-[82%] rounded-xl rounded-br-sm border px-3.5 py-2.5 text-left text-sm leading-6" : "min-w-0 flex-1 text-left text-sm leading-6"}
|
||||
style={
|
||||
isUser
|
||||
? {
|
||||
color,
|
||||
background: `color-mix(in srgb, ${theme.node.text} 7%, ${theme.toolbar.panel})`,
|
||||
borderColor: `color-mix(in srgb, ${theme.node.text} 14%, transparent)`,
|
||||
}
|
||||
: { color }
|
||||
}
|
||||
>
|
||||
{isUser ? (
|
||||
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
|
||||
<div className="whitespace-pre-wrap break-words">{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}
|
||||
{item.meta ? <div className={`mt-1 text-[11px] opacity-45 ${isUser ? "text-right" : ""}`}>{item.meta}</div> : null}
|
||||
</div>
|
||||
{isUser ? <AgentUserAvatar user={user} theme={theme} /> : null}
|
||||
</div>
|
||||
@@ -303,7 +314,7 @@ function toolCardState(title: string, text: string, detail?: unknown) {
|
||||
if (objectField(detail, "status") === "noop" || /未生效|无需|没有找到|没有.*可|已存在/.test(raw)) return { label: "未生效", color: "#d97706", softBorder: "rgba(217,119,6,.22)", softBg: "rgba(217,119,6,.04)", icon: <CircleAlert className="size-4" />, isError: false };
|
||||
if (/拒绝|取消/.test(raw) || lower.includes("rejected")) return { label: "拒绝执行", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (/失败|错误/.test(raw) || lower.includes("failed") || lower.includes("error")) return { label: "执行失败", color: "#dc2626", softBorder: "rgba(220,38,38,.20)", softBg: "rgba(220,38,38,.04)", icon: <XCircle className="size-4" />, isError: true };
|
||||
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "执行完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
if (/完成|成功/.test(raw) || lower.includes("completed") || lower.includes("succeeded")) return { label: tool === "canvas_apply_ops" || /画布操作/.test(title) ? "已批准执行" : "工具完成", color: "#16a34a", softBorder: "rgba(22,163,74,.20)", softBg: "rgba(22,163,74,.04)", icon: <CheckCircle2 className="size-4" />, isError: false };
|
||||
return { label: "工具调用", color: "#2563eb", softBorder: "rgba(37,99,235,.20)", softBg: "rgba(37,99,235,.04)", icon: <Wrench className="size-4" />, isError: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -116,11 +116,11 @@ 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>
|
||||
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative rounded-xl">
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
|
||||
@@ -146,9 +146,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
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(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
@@ -156,6 +156,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
background: node.metadata?.background ?? globalConfig.background ?? defaultConfig.background,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { ImageIcon, List, Music2, Settings2, Video, X } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { listNodeDefinitions, useNodeRegistryVersion } from "@/lib/canvas/node-registry";
|
||||
import { CanvasNodeType, type ConnectionHandle, type Position } from "@/types/canvas";
|
||||
|
||||
export type PendingConnectionCreate = {
|
||||
connection: ConnectionHandle;
|
||||
position: Position;
|
||||
};
|
||||
|
||||
export function ConnectionCreateMenu({
|
||||
pending,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
pending: PendingConnectionCreate;
|
||||
onCreate: (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[120] w-[300px] rounded-[18px] border p-3 shadow-2xl backdrop-blur"
|
||||
data-connection-create-menu
|
||||
style={{ left: pending.position.x, top: pending.position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
引用该节点生成
|
||||
</span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-lg text-base opacity-55 transition hover:bg-white/10 hover:opacity-100" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
<ConnectionCreateOption theme={theme} icon={<List className="size-5" />} title="文本生成" description="脚本、广告词、品牌文案" onClick={() => onCreate(CanvasNodeType.Text)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<ImageIcon className="size-5" />} title="图片生成" onClick={() => onCreate(CanvasNodeType.Image)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Video className="size-5" />} title="视频生成" onClick={() => onCreate(CanvasNodeType.Video)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Music2 className="size-5" />} title="音频参考" onClick={() => onCreate(CanvasNodeType.Audio)} />
|
||||
<ConnectionCreateOption theme={theme} icon={<Settings2 className="size-5" />} title="配置节点" description="模型、尺寸、数量和输入顺序" onClick={() => onCreate(CanvasNodeType.Config)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionCreateOption({ theme, icon, title, description, onClick }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; icon: React.ReactNode; title: string; description?: string; onClick?: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-16 w-full cursor-pointer items-center gap-3 rounded-2xl px-3 text-left transition"
|
||||
style={{ color: theme.node.text }}
|
||||
onClick={onClick}
|
||||
onMouseEnter={(event) => (event.currentTarget.style.background = theme.node.fill)}
|
||||
onMouseLeave={(event) => (event.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
<span className="grid size-11 shrink-0 place-items-center rounded-xl" style={{ background: theme.node.fill, color: theme.node.muted }}>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-base font-semibold leading-5">{title}</span>
|
||||
{description ? (
|
||||
<span className="mt-1 block truncate text-sm" style={{ color: theme.node.muted }}>
|
||||
{description}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeCreateMenu({ position, onCreate, onClose }: { position: Position; onCreate: (type: string) => void; onClose: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
useNodeRegistryVersion();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const definitions = listNodeDefinitions().filter((def) => def.showInCreateMenu !== false);
|
||||
// 点击菜单外的空白处自动关闭
|
||||
useEffect(() => {
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
return () => document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="absolute z-[120] max-h-[70vh] w-[300px] overflow-y-auto rounded-[18px] border p-3 shadow-2xl backdrop-blur thin-scrollbar"
|
||||
data-canvas-no-zoom
|
||||
style={{ left: position.x, top: position.y, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<span className="text-sm font-medium" style={{ color: theme.node.muted }}>
|
||||
选择节点
|
||||
</span>
|
||||
<button type="button" className="grid size-7 place-items-center rounded-lg opacity-55 transition hover:opacity-100" onClick={onClose} aria-label="关闭">
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{definitions.map((def) => (
|
||||
<ConnectionCreateOption key={def.type} theme={theme} icon={def.icon} title={def.title} description={def.description} onClick={() => onCreate(def.type)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user