Compare commits

...

27 Commits

Author SHA1 Message Date
HouYunFei 0e1edd1484 chore(release): bump version to v0.9.0 and update changelog 2026-07-17 16:43:01 +08:00
HouYunFei 5107be0acf feat(canvas): 资产卡增删改重做 + 节点名称默认隐藏
- 资产 Tab 卡片重做:视频取首帧封面、文本铺内容、去标题成矩形,hover 居中显示插入/删除两个 icon 按钮
- 修复删除确认框「取消」经 React Portal 事件冒泡误触插入
- 资产 Tab 支持上传添加图片/视频、卡片移除资产
- 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现
- 操作按钮改用扁平无灰底样式,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:42:08 +08:00
HouYunFei 219b3e84db feat(canvas): 左侧面板可调宽收起 + 元素多选导出与跳转动画
- 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),状态与宽度持久化
- 顶栏菜单左侧新增面板开关按钮,并缩小菜单/开关按钮
- 顶栏菜单新增「导出当前画布」为压缩包
- 画布元素列表支持多选并批量导出:图片/视频/音频为文件、文本为 txt、其余各自为 json
- 画布/资产切换改为滑动下划线动画,点击元素跳转带缓动动画
- 移除文本/配置/视频等非图片元素图标的灰色底色,并写入 AGENTS.md 规范

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 16:21:26 +08:00
HouYunFei a3411cf9d9 docs(changelog): note the drag-resize React #185 crash fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:17:57 +08:00
HouYunFei 430d1bd956 refactor(canvas-project): extract plugin host into usePluginHost hook
Move the plugin node host capabilities (pluginAi generation bridge, pluginHost
canvas accessors, renderPluginPanel, buildNodeToolbarItems) and the plugin-load
effect out of InfiniteCanvasPage into usePluginHost. The hook receives the
shared config/refs/setters explicitly and keeps identical dependency arrays.

This also relocates the AI-service and plugin imports (requestVideoGeneration,
selectableModelsByCapability, buildNodeContext, ensurePluginsLoaded, the
canvas-plugin types, etc.) that were only used by this cluster.

project.tsx is now ~2950 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:19:00 +08:00
HouYunFei cf0561d12a refactor(canvas-project): extract agent bridge into useAgentBridge hook
Move the canvas<->local-Agent bridge (agentSnapshot memo, applyAgentOps /
undoAgentOps, agentUndoSnapshot state, and the publish-to-agent-store effect)
out of InfiniteCanvasPage into a useAgentBridge hook. Only applyAgentOps is
consumed outside (by the plugin host); the rest is fully encapsulated.

The hook receives the shared canvas state/refs/setters explicitly and keeps the
exact same dependency arrays, so behavior is unchanged. project.tsx drops the
now-unused agent-ops imports and the setCanvasContext selector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:14:10 +08:00
HouYunFei e6dcd7a832 refactor(canvas-project): extract CanvasTopBar into its own component
Move CanvasTopBar and its private helpers (MenuLabel, CompactAgentStatus,
Shortcut) out of project.tsx into canvas-top-bar.tsx. The component already took
all data via explicit props, so the move is mechanical with no behavior change.
Drop the imports (DOCS_URL, UserStatusActions, Dropdown, several icons) that
were only used by the top bar.

project.tsx is now ~3080 lines, down from 3536 at the start of the refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:04:25 +08:00
HouYunFei 187c499ef9 refactor(canvas-project): extract refresh shell and create menus into components
Move the module-scope presentational subcomponents out of project.tsx:
- canvas-refresh-shell.tsx: CanvasRefreshShell (pre-load placeholder).
- canvas-create-menus.tsx: ConnectionCreateMenu, ConnectionCreateOption,
  NodeCreateMenu, plus the PendingConnectionCreate type they define.

They already communicated via explicit props, so the move is mechanical. No
behavior change; project.tsx imports them back and drops now-unused icon and
node-registry imports.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:00:53 +08:00
HouYunFei cf1ea2646f refactor(canvas-project): extract pure helpers into @/lib/canvas modules
Move the ~30 module-scope pure functions out of the 3.5k-line project.tsx into
three focused lib modules, with identical names and signatures. No behavior
change; project.tsx imports them back.

- canvas-node-factory.ts: createCanvasNode, *Metadata builders, referenceUrl,
  applyNodeConfigPatch.
- canvas-node-geometry.ts: nodeBounds, group drop/snap, connection anchors,
  normalizeConnection, hidden-batch checks.
- canvas-generation-helpers.ts: generation config/inputs, reference hydration,
  retry-source lookup, angle prompt builders, misc pure helpers.

project.tsx drops from 3536 to ~3230 lines. Also removes imports that were only
used by the moved helpers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 13:46:07 +08:00
HouYunFei 57db0fe752 feat(canvas-local-agent-panel): optimize state management and rendering performance 2026-07-17 13:38:55 +08:00
HouYunFei c5f53454aa feat(changelog): add support for Google Analytics 4 and Baidu statistics 2026-07-17 11:32:11 +08:00
HouYunFei ca6efdf0c3 feat(analytics): implement runtime configuration and analytics tracking for GA4 and Baidu 2026-07-17 11:24:22 +08:00
HouYunFei a2586ffe34 feat(canvas-node-manual): enhance input box to support resource references with thumbnail display 2026-07-17 11:19:34 +08:00
HouYunFei 5a27aae99e feat(changelog): update entries for canvas node enhancements and image reference display 2026-07-17 10:15:16 +08:00
HouYunFei 66961ce10f feat(canvas-prompt-chip-input): implement new component for enhanced prompt input with reference chips 2026-07-17 10:06:57 +08:00
HouYunFei 0c4288b832 chore: update changelog for v0.8.2 and bump version to v0.8.2 2026-07-16 17:44:20 +08:00
HouYunFei 093260f7b2 feat(image-settings-panel): clarify transparent background feature availability in tooltip 2026-07-16 17:44:05 +08:00
HouYunFei 805573b980 feat(prompt-chip-input): implement contentEditable input for image references with thumbnail display 2026-07-16 17:32:43 +08:00
HouYunFei e6829333c5 feat(canvas-node): enhance input area styling and improve cursor visibility in prompt panel 2026-07-16 17:28:30 +08:00
HouYunFei 2959b5d340 feat(image-settings): add transparent background toggle for image generation 2026-07-16 15:45:02 +08:00
HouYunFei 2f9776bee3 chore: bump version to v0.8.1 2026-07-16 15:12:02 +08:00
HouYunFei 4beadc6161 feat(markdown-node): improve rendering performance and user interaction by caching parsed Markdown and optimizing DOM updates 2026-07-16 15:11:48 +08:00
HouYunFei 13c508bc34 feat(svg-node): enhance SVG node with transparent background rendering and improved editing experience 2026-07-16 14:13:43 +08:00
HouYunFei 5f627d5baf feat(sticky-note): update sticky note plugin with customizable colors, improved editing experience, and enhanced drag-and-drop functionality 2026-07-16 13:57:47 +08:00
HouYunFei 3f179a607a feat(panorama): enhance 3D panorama node with AI generation support and unified interaction toggle 2026-07-16 13:21:25 +08:00
HouYunFei 2d2c7e3d5e feat(plugin-manager): add upgrade notification for installed plugins and improve version handling 2026-07-16 10:36:41 +08:00
HouYunFei 3f623ea70c feat(html-node): enhance HTML node plugin with interactive toolbar and improved editor functionality 2026-07-15 17:51:39 +08:00
73 changed files with 3749 additions and 1283 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile
run: bun install
- name: Build
working-directory: web
+2
View File
@@ -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` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
+30
View File
@@ -2,6 +2,36 @@
## Unreleased
## 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 动态安装/启用/更新/卸载远程节点插件。
+2
View File
@@ -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
+6 -24
View File
@@ -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>
有广告赞助意向请通过上方联系方式沟通。
## 社区支持
+1 -1
View File
@@ -1 +1 @@
v0.8.0
v0.9.0
+4
View File
@@ -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`,请手动填写模型名。
+2 -1
View File
@@ -4,6 +4,7 @@
"defaultOpen": true,
"pages": [
"local-development",
"canvas-data-structure"
"canvas-data-structure",
"local-codex-canvas"
]
}
+39
View File
@@ -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>
+15
View File
@@ -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)。
## 提示词库
前台提示词库支持:
+1 -2
View File
@@ -9,7 +9,6 @@
"render",
"docker",
"third-party-prompt-repositories",
"[在线体验](https://canvas.best/)",
"local-codex-canvas"
"[在线体验](https://canvas.best/)"
]
}
+1
View File
@@ -5,6 +5,7 @@
"pages": [
"[更新日志](/docs/progress/changelog)",
"local-agent-integration-plan",
"prompt-chip-input-plan",
"pending-test",
"todo"
]
+6 -1
View File
@@ -5,9 +5,14 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 左侧画布面板「资产」Tab:搜索框旁「添加」按钮可上传图片/视频文件(支持多选)加入资产库;每张资产卡片悬停显示移除按钮,二次确认后删除;点击卡片仍插入到画布;需验证添加图片/视频、非图片视频文件的提示、移除确认与插入行为。
- 左侧画布面板:支持拖拽右边缘调整宽度(记忆到本地)、通过顶栏菜单左侧的开关按钮展开/收起,展开收起有动画;需验证宽度拖拽、动画、刷新后保持展开/收起状态与宽度。
- 左侧画布面板:「画布/资产」切换改为带滑动下划线的动画;点击「画布元素」列表项跳转到对应节点时带缓动动画(视图平滑移动缩放而非瞬移);文本/配置/视频/音频等非图片元素的图标去掉灰色底色;需验证视觉与交互。
- 左侧画布面板:画布元素列表点击「选择」进入多选模式,可勾选/全选节点后「导出选中」为压缩包;压缩包为扁平结构——图片/视频/音频直接是对应文件(按节点名称命名、重名自动加序号),文本节点为 `.txt`,其余无法识别的节点各自导出为同名 `.json`;需验证多选、全选、导出内容与命名正确。
- 顶栏菜单:新增「导出当前画布」,导出当前画布为压缩包(与项目列表页单个导出格式一致);需验证导出文件名与内容。
- 画布节点插件系统:内置节点(图片/文本/视频/音频/配置/组)改为统一注册表管理,创建菜单由注册表动态生成;需验证六种内置节点的创建、渲染、缩放、连线、小地图颜色、作为生成输入等行为与之前一致。内置节点仅这六种,其余均为插件。
- 画布节点插件系统:左上菜单新增「节点插件」,可通过 URL 安装远程插件、启用/禁用、更新、卸载;安装时有安全警告说明插件代码在页面内执行、可访问本地数据。可用 `/plugins/sticky-note.js` 安装便利贴节点验证:换色、编辑、「衍生文本节点」(演示 applyOps 增节点+连线)。
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 {{input}} 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互。
- 画布节点插件系统:示例插件 Markdown(编辑/渲染)、HTML(沙箱 iframe 渲染,支持 `{{input}}` 注入上游文本)、SVG(渲染/编辑,可取上游文本 SVG 源码)、3D 全景(three.js CDN 动态加载,可从上游图片节点取全景图并拖拽查看);均为远程插件,需分别安装 `/plugins/markdown.js`、`/plugins/html.js`、`/plugins/svg.js`、`/plugins/panorama.js` 后验证创建、渲染、编辑及与上游节点的交互。
- 画布节点插件系统:卸载或禁用插件后,画布上遗留的该类型节点显示「缺少插件」占位且数据保留,重新安装/启用后恢复渲染;需验证刷新后已启用插件自动加载。
- 画布节点插件系统:每个插件为 `plugins/canvas/<name>/` 独立目录(各自 package.json + esbuild + src + README,互不耦合),`npm run build` 产物落到 `dist/<name>.js` 并同步到 `web/public/plugins/`;需验证各插件可独立构建与安装。
- 画布节点插件本地开发:每个插件支持 `npm run dev`(watch 自动构建并同步到 `web/public/plugins/`),配合 `web/.env.local` 的 `VITE_DEV_PLUGINS`(逗号分隔 URL)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效。
@@ -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` 默认 truevalue 是提示词字符串,引用以 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 ← DOMsyncFromEditor**:仿 `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. **占位符 & 样式**:空值时显示 placeholderabsolute 占位层);容器套用面板传入的 `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」文本(生成结果正常引用到图)。
- 中文输入法输入不吞字、不误触发发送。
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
-25
View File
@@ -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>
-1
View File
@@ -4,7 +4,6 @@
"defaultOpen": true,
"pages": [
"security",
"donate",
"sponsor"
]
}
+19 -5
View File
@@ -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
View File
@@ -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)
+1 -1
View File
@@ -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() {
+5
View File
@@ -7,4 +7,9 @@ server {
location / {
try_files $uri $uri/ /index.html;
}
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
location = /config.js {
add_header Cache-Control "no-store";
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-html",
"version": "1.0.0",
"version": "1.2.0",
"private": true,
"type": "module",
"description": "Infinite Canvas HTML 节点插件",
+99 -18
View File
@@ -1,10 +1,67 @@
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
import { definePlugin, useMemo, useState } from "@infinite-canvas/plugin-sdk";
// 交互:预览态 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 [editing, setEditing] = useState(false);
const value = ctx.node.metadata?.content || "";
const editing = Boolean(ctx.node.metadata?.editing);
const upstreamText = useMemo(
() =>
ctx
@@ -16,23 +73,29 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
);
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
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;
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 onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
{editing ? "👁" : "✎"}
</button>
{editing ? (
<textarea autoFocus value={value} placeholder="<div>Hello, {{input}}</div>" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 12, outline: "none", border: "none", color: ctx.theme.node.text }} />
) : value ? (
<iframe title="html-preview" sandbox="allow-scripts allow-forms" style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff" }} srcDoc={html} />
) : (
<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: 14 }}> HTML </span>
</div>
)}
<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>
);
}
@@ -40,7 +103,7 @@ function HtmlContent({ ctx }: CanvasNodeContentProps) {
export default definePlugin({
id: "html",
name: "HTML 节点",
version: "1.0.0",
version: "1.2.0",
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
nodes: [
{
@@ -51,7 +114,25 @@ export default definePlugin({
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 }),
},
];
},
},
],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-markdown",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas Markdown 节点插件",
+75 -21
View File
@@ -1,6 +1,10 @@
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
// 切「交互」态可滚动/选择;编辑态强制可交互。
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
import { definePlugin, useEffect, useMemo, useState } from "@infinite-canvas/plugin-sdk";
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
import css from "./styles.css";
@@ -15,44 +19,76 @@ function loadMarked(): Promise<Marked> {
return markedPromise;
}
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
// marked 就绪状态:已缓存则首帧即为 true,避免重渲染/重挂载时闪成空白
const [markedReady, setMarkedReady] = useState(Boolean(marked));
const value = ctx.node.metadata?.content || "";
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 && setMarkedReady(true));
loadMarked().then(() => alive && force((n) => n + 1));
return () => {
alive = false;
};
}, []);
// marked 就绪后同步计算 HTML(按 value 记忆),不再用异步 setState 填充,消除闪烁
const html = useMemo(() => (marked ? marked.parse(value || "*双击右上角按钮编辑 Markdown*") : ""), [value, markedReady]);
const source = (ctx.node.metadata?.content as string | undefined) || "";
const html = renderMarkdown(source);
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;
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 (
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑"}>
{editing ? "👁" : "✎"}
</button>
{editing ? (
<textarea autoFocus value={value} placeholder="# 输入 Markdown" onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }} />
) : (
<div className="cnv-md" onWheel={(e) => e.stopPropagation()} style={{ color: ctx.theme.node.text }} dangerouslySetInnerHTML={{ __html: html }} />
)}
</div>
<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.0.0",
version: "1.1.0",
description: "在画布中编辑与渲染 Markdown",
css,
nodes: [
@@ -64,8 +100,26 @@ export default definePlugin({
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 }),
},
];
},
},
],
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-panorama",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas 3D 全景节点插件",
+269 -86
View File
@@ -1,6 +1,8 @@
// 3D 全景节点:等距柱状(equirectangular)全景查看器。three.js 从 CDN 按需加载
import { definePlugin, useEffect, useRef } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
// 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;
@@ -9,120 +11,301 @@ function loadThree(): Promise<any> {
return threePromise;
}
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
// 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 upstreamImage = ctx
.getUpstream()
.map((node) => node.metadata?.content)
.find(Boolean);
const source = ctx.node.metadata?.content || upstreamImage;
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
useEffect(() => {
const mount = mountRef.current;
if (!mount || !source) return;
if (!mount || !src) return;
let disposed = false;
let cleanup = () => {};
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 = () => {};
loadThree().then((THREE: any) => {
if (disposed || !mountRef.current) return;
const width = mount.clientWidth || 400;
const height = mount.clientHeight || 300;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, width / height, 0.1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
mount.appendChild(renderer.domElement);
setStatus("loading");
const geometry = new THREE.SphereGeometry(50, 60, 40);
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
const texture = new THREE.TextureLoader().load(source);
texture.colorSpace = THREE.SRGBColorSpace;
const sphere = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ map: texture }));
scene.add(sphere);
loadThree()
.then((THREE: any) => {
if (disposed || !mountRef.current) return;
let lon = 0;
let lat = 0;
let dragging = false;
let startX = 0;
let startY = 0;
let startLon = 0;
let startLat = 0;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
const target = new THREE.Vector3();
const onDown = (event: PointerEvent) => {
dragging = true;
startX = event.clientX;
startY = event.clientY;
startLon = lon;
startLat = lat;
};
const onMove = (event: PointerEvent) => {
if (!dragging) return;
lon = startLon - (event.clientX - startX) * 0.2;
lat = Math.max(-85, Math.min(85, startLat + (event.clientY - startY) * 0.2));
};
const onUp = () => (dragging = false);
const canvas: HTMLCanvasElement = renderer.domElement;
canvas.style.cursor = "grab";
canvas.addEventListener("pointerdown", onDown);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
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);
let frame = 0;
const animate = () => {
frame = requestAnimationFrame(animate);
if (!dragging) lon += 0.03; // 缓慢自转
const phi = THREE.MathUtils.degToRad(90 - lat);
const theta = THREE.MathUtils.degToRad(lon);
camera.lookAt(new THREE.Vector3(Math.sin(phi) * Math.cos(theta), Math.cos(phi), Math.sin(phi) * Math.sin(theta)));
renderer.render(scene, camera);
};
animate();
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));
cleanup = () => {
cancelAnimationFrame(frame);
canvas.removeEventListener("pointerdown", onDown);
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
renderer.dispose();
geometry.dispose();
texture.dispose();
if (canvas.parentElement === mount) mount.removeChild(canvas);
};
});
// 视角状态
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;
cleanup();
cleanupEvents();
resizeObserver?.disconnect();
if (frame) cancelAnimationFrame(frame);
texture?.dispose?.();
material?.dispose?.();
geometry?.dispose?.();
if (renderer) {
renderer.domElement?.remove?.();
renderer.dispose?.();
renderer.forceContextLoss?.();
}
};
}, [source]);
}, [src]);
if (!source) {
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: 14 }}></span>
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>
);
}
return <div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }} />;
<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.0.0",
description: "查看 360° 等距柱状全景图,可从上游图片节点取图",
version: "1.1.0",
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
nodes: [
{
type: "panorama:viewer",
title: "3D 全景",
icon: "🌐",
description: "360° 全景查看器",
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: "" }),
},
]
: []),
];
},
},
],
});
+26 -15
View File
@@ -3,7 +3,7 @@
// CI(publish-plugins.yml)把 dist/ 强推到 plugins-dist 分支,前端经 jsDelivr 远程拉取。
// 本地自测:`npm install && npm run build`,再把 VITE_PLUGIN_REGISTRY_URL 指向本地 dist。
import { build } from "esbuild";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
@@ -11,15 +11,24 @@ 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 节点", version: "1.0.0", description: "在画布中编辑与渲染 Markdown", icon: "📝" },
{ id: "svg", dir: "svg", name: "SVG 节点", version: "1.0.0", description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码", icon: "🔷" },
{ id: "html", dir: "html", name: "HTML 节点", version: "1.0.0", description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本", icon: "🌐" },
{ id: "panorama", dir: "panorama", name: "3D 全景节点", version: "1.0.0", description: "查看 360° 等距柱状全景图,可从上游图片节点取图", icon: "🧭" },
{ id: "sticky-note", dir: "sticky-note", name: "便利贴节点", version: "1.0.0", description: "可换色、可编辑、可衍生文本节点的便利贴", icon: "📌" },
{ 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 });
@@ -45,14 +54,16 @@ for (const plugin of OFFICIAL) {
const manifest = {
// 清单结构版本;entry 为相对本清单的 bundle 文件名,由前端解析成绝对 URL
version: 1,
plugins: OFFICIAL.map((plugin) => ({
id: plugin.id,
name: plugin.name,
version: plugin.version,
description: plugin.description,
icon: plugin.icon,
entry: `${plugin.id}.js`,
})),
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/`);
+94 -1
View File
@@ -135,6 +135,70 @@ export type CanvasAgentOp =
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;
};
// ---------------------------------------------------------------------------
// 节点上下文:每个节点渲染时注入,是插件与画布交互的核心接口
// ---------------------------------------------------------------------------
@@ -150,6 +214,7 @@ 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;
// 图访问
@@ -163,6 +228,11 @@ export type CanvasNodeContext = {
// 节点间/插件间通信
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;
};
@@ -184,6 +254,16 @@ export type CanvasNodeToolbarItem = {
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;
@@ -194,11 +274,24 @@ export type CanvasNodeDefinition = {
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>; // 节点下方面板
Panel?: ComponentType<CanvasNodePanelProps>; // 节点下方面板(自定义)
toolbar?: (ctx: CanvasNodeContext) => CanvasNodeToolbarItem[];
onDoubleClick?: (ctx: CanvasNodeContext) => boolean; // 返回 true 表示已处理
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-sticky-note",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas 便利贴节点插件",
+126 -41
View File
@@ -1,55 +1,139 @@
// 便利贴节点:可换色、可编辑、可衍生文本节点(演示 ctx.applyOps 画布指令集)
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
// 便利贴节点:纯展示便利贴——整块可拖动、双击编辑、右上角自选颜色
// 不再声明 resource(避免宿主在右上角显示「文本N」资源角标),也不再衍生节点。
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
const COLORS = ["#fde68a", "#fca5a5", "#a7f3d0", "#bfdbfe", "#ddd6fe"];
// 预设便签色(点选切换),并额外提供自定义取色
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);
// pluginColor 是插件自定义 metadata 字段(非内置字段),读出为 unknown,按需断言
const color = (ctx.node.metadata?.pluginColor as string | undefined) || COLORS[0];
const content = ctx.node.metadata?.content || "";
const [paletteOpen, setPaletteOpen] = useState(false);
// 取色时的本地预览色:仅本组件即时重渲染,避免每次都写宿主 store
const [draftColor, setDraftColor] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const commitTimerRef = useRef<number | null>(null);
const cycleColor = () => {
const next = COLORS[(COLORS.indexOf(color) + 1) % COLORS.length];
ctx.updateMetadata({ pluginColor: next });
// 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 spawnTextNode = () => {
const id = `sticky-${ctx.node.id}-${ctx.getNodes().length}`;
ctx.applyOps([
{
type: "add_node",
id,
nodeType: "text",
title: "便利贴衍生",
x: ctx.node.position?.x ?? 0,
y: (ctx.node.position?.y ?? 0) + 260,
metadata: { content, status: "success" },
},
{ type: "connect_nodes", fromNodeId: ctx.node.id, toNodeId: id },
]);
// 立即提交(点击预设色、取色器关闭时用):清掉待提交的节流并写入。
const commitColor = (next: string) => {
if (commitTimerRef.current) {
clearTimeout(commitTimerRef.current);
commitTimerRef.current = null;
}
setDraftColor(next);
pickColor(next);
};
const btn = { width: 26, height: 26, borderRadius: 8, border: "none", cursor: "pointer", background: "rgba(0,0,0,.08)", fontSize: 13 } as const;
useEffect(() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
}, []);
// 宿主里的颜色一旦真正变化(提交完成 / 撤销重做),清掉本地预览,回到 store 为准。
// 拖动取色期间 committedColor 不变,draftColor 得以保留,故不影响即时预览。
useEffect(() => {
setDraftColor(null);
}, [committedColor]);
// 交互控件上按下时阻止冒泡,避免误触发节点拖动/双击编辑
const stop = (e: { stopPropagation: () => void }) => e.stopPropagation();
return (
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column", background: color, borderRadius: 16, padding: 12, boxSizing: "border-box" }}>
<div style={{ display: "flex", gap: 6, justifyContent: "flex-end", marginBottom: 6 }}>
<button type="button" title="换颜色" onClick={cycleColor} style={btn}>
🎨
</button>
<button type="button" title="衍生文本节点" onClick={spawnTextNode} style={btn}>
</button>
<button type="button" title={editing ? "完成" : "编辑"} onClick={() => setEditing((v) => !v)} style={btn}>
{editing ? "✓" : "✎"}
</button>
<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} onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ flex: 1, resize: "none", border: "none", outline: "none", background: "transparent", color: "#1c1917", fontSize: 15, lineHeight: 1.5 }} />
<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: "auto", color: "#1c1917", fontSize: 15, lineHeight: 1.5 }}>{content || "双击编辑便利贴"}</div>
<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>
);
@@ -58,8 +142,8 @@ function StickyNoteContent({ ctx }: CanvasNodeContentProps) {
export default definePlugin({
id: "sticky-note",
name: "便利贴节点",
version: "1.0.0",
description: "可换色、可编辑、可衍生文本节点的便利贴",
version: "1.1.0",
description: "可自选颜色、双击编辑、拖动即可移动的便利贴",
nodes: [
{
type: "sticky-note:note",
@@ -67,9 +151,10 @@ export default definePlugin({
icon: "📌",
description: "彩色便利贴",
defaultSize: { width: 240, height: 200 },
defaultMetadata: { content: "", pluginColor: "#fde68a" },
defaultMetadata: { content: "", pluginColor: DEFAULT_COLOR },
minimapColor: "#f59e0b",
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
// 纯记事节点:不弹出下方生成面板(默认会是「生成图片」的提示词面板)
hidePanel: true,
Content: StickyNoteContent,
},
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "canvas-plugin-svg",
"version": "1.0.0",
"version": "1.1.0",
"private": true,
"type": "module",
"description": "Infinite Canvas SVG 节点插件",
+56 -13
View File
@@ -1,34 +1,75 @@
// SVG 节点:编辑与渲染 SVG,无自身内容时可取上游文本节点里的 SVG 源码。
import { definePlugin, useEffect, useState } from "@infinite-canvas/plugin-sdk";
// SVG 节点:编辑与渲染 SVG,透明背景直接融入画布;无自身内容时可取上游文本节点里的 SVG 源码。
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
const DEFAULT_SVG = '<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40" fill="#6366f1"/></svg>';
function SvgContent({ ctx }: CanvasNodeContentProps) {
const [editing, setEditing] = useState(false);
const stored = ctx.node.metadata?.content;
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?.trim().startsWith("<svg"));
.find((text): text is string => typeof text === "string" && text.trim().startsWith("<svg"));
const value = stored ?? "";
const svg = value.trim() || upstream || DEFAULT_SVG;
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 data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ position: "relative", height: "100%", width: "100%", display: "flex", flexDirection: "column" }}>
<button type="button" style={toggle} onClick={() => setEditing((v) => !v)} title={editing ? "预览" : "编辑源码"}>
<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={DEFAULT_SVG} onChange={(e) => ctx.updateMetadata({ content: e.target.value })} onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", resize: "none", background: "transparent", padding: 16, fontFamily: "monospace", fontSize: 12, outline: "none", border: "none", color: ctx.theme.node.text }} />
<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 }} 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>
);
@@ -37,8 +78,8 @@ function SvgContent({ ctx }: CanvasNodeContentProps) {
export default definePlugin({
id: "svg",
name: "SVG 节点",
version: "1.0.0",
description: "编辑与渲染 SVG,可接收上游文本节点的 SVG 源码",
version: "1.1.0",
description: "透明背景渲染 SVG 矢量图,可接收上游文本节点的 SVG 源码",
nodes: [
{
type: "svg:vector",
@@ -48,6 +89,8 @@ export default definePlugin({
defaultSize: { width: 320, height: 320 },
defaultMetadata: {},
minimapColor: "#14b8a6",
// 背景/边框透明,矢量图直接融入画布
transparentBackground: true,
Content: SvgContent,
},
],
+22
View File
@@ -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
+1
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
@@ -120,7 +120,7 @@ export function CanvasConfigComposer({ value, inputs, onChange, onClose }: Canva
</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}
@@ -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>
);
}
@@ -8,7 +8,8 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { randomId } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
import { useShallow } from "zustand/react/shallow";
import { useAgentStore, type AgentAttachment, type AgentCanvasContext, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
@@ -46,9 +47,40 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const { message, modal } = App.useApp();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, canvasContext, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useAgentStore();
// 逐字段 selector + useShallow:只有这些字段变化时才重渲染。
// 注意:canvasContext 不在此订阅内 —— 它在拖拽/resize 时会被 project 每帧写入,
// 但面板只在 ref 同步与防抖 postState 中用到它、渲染层从不读它。若把它放进订阅,
// 面板会随画布每帧重渲染(性能问题,也是 #185 崩溃的放大器)。改为下方 subscribe 命令式监听。
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool } = useAgentStore(
useShallow((state) => ({
width: state.width,
url: state.url,
token: state.token,
connected: state.connected,
enabled: state.enabled,
prompt: state.prompt,
attachments: state.attachments,
sending: state.sending,
waiting: state.waiting,
messages: state.messages,
eventLogs: state.eventLogs,
threads: state.threads,
activeThreadId: state.activeThreadId,
workspacePath: state.workspacePath,
loadingThreads: state.loadingThreads,
activeTab: state.activeTab,
confirmTools: state.confirmTools,
activity: state.activity,
connectError: state.connectError,
pendingTool: state.pendingTool,
})),
);
const setAgentState = useAgentStore((state) => state.setAgentState);
const pushMessage = useAgentStore((state) => state.addMessage);
const pushEventLog = useAgentStore((state) => state.addEventLog);
const clearEventLogs = useAgentStore((state) => state.clearEventLogs);
const listRef = useRef<HTMLDivElement>(null);
const canvasContextRef = useRef(canvasContext);
const canvasContextRef = useRef<AgentCanvasContext | null>(useAgentStore.getState().canvasContext);
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
const autoConnectRef = useRef(false);
@@ -81,9 +113,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
}
}, [endpoint, setAgentState, token]);
// canvasContext 命令式订阅:保持 ref 最新,并在快照变化时防抖上报,全程不触发面板重渲染。
useEffect(() => {
canvasContextRef.current = canvasContext;
}, [canvasContext]);
let timer: ReturnType<typeof setTimeout> | null = null;
const unsubscribe = useAgentStore.subscribe((state) => {
if (state.canvasContext === canvasContextRef.current) return;
canvasContextRef.current = state.canvasContext;
if (!useAgentStore.getState().connected) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContextRef.current?.snapshot || null), 300);
});
return () => {
unsubscribe();
if (timer) clearTimeout(timer);
};
}, [endpoint, token]);
useEffect(() => {
confirmToolsRef.current = confirmTools;
}, [confirmTools]);
@@ -156,12 +200,6 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (connected) void loadThreads();
}, [connected, loadThreads]);
useEffect(() => {
if (!connected) return;
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300);
return () => clearTimeout(timer);
}, [canvasContext?.snapshot, connected, endpoint, token]);
const sendPrompt = async () => {
const text = prompt.trim();
const files = attachments;
@@ -175,7 +213,11 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
try {
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }),
});
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
const data = (await res.json()) as { threadId?: string };
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
@@ -211,12 +253,14 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
const prev = useAgentStore.getState().attachments;
try {
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
const url = URL.createObjectURL(file);
attachmentUrlsRef.current.add(url);
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
}));
const next = await Promise.all(
images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
const url = URL.createObjectURL(file);
attachmentUrlsRef.current.add(url);
return { id: createId(), name: file.name, type: file.type, size: file.size, url, dataUrl };
}),
);
const merged = [...prev, ...next];
if (attachmentPayloadBytes(merged) > MAX_ATTACHMENT_PAYLOAD_BYTES) {
next.forEach((item) => {
@@ -295,7 +339,12 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
addMessage({
role: "tool",
title: `${toolName(payload.name)}完成`,
text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成",
detail: { requestId: payload.requestId, name: payload.name, input, result },
});
} catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败";
setAgentState({ activity: "工具失败", waiting: false });
@@ -457,7 +506,7 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) {
setAgentState({ messages: currentMessages.map((message, i) => i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message) });
setAgentState({ messages: currentMessages.map((message, i) => (i === index ? { ...message, ...next, id: message.id, text: next.text || message.text } : message)) });
return;
}
}
@@ -554,7 +603,15 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
{messages.map((item) => (
<AgentChatMessage key={item.id} item={agentMessageToChatMessage(item)} theme={theme} user={user} />
))}
{pendingTool ? <AgentPendingToolCard summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)} detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }} theme={theme} onReject={rejectPendingTool} onApprove={approvePendingTool} /> : null}
{pendingTool ? (
<AgentPendingToolCard
summary={summarizeCanvasAgentOps(pendingTool.input?.ops || []) || toolName(pendingTool.name)}
detail={{ requestId: pendingTool.requestId, name: pendingTool.name, input: pendingTool.input }}
theme={theme}
onReject={rejectPendingTool}
onApprove={approvePendingTool}
/>
) : null}
{waiting && !pendingTool ? <AgentWorkingMessage theme={theme} /> : null}
</div>
<AgentChatComposer
@@ -569,7 +626,13 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
onStop={stopTurn}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
left={
attachments.length ? (
<span className="text-[11px]" style={{ color: theme.node.muted }}>
{formatBytes(attachmentPayloadBytes(attachments))} / 30MB
</span>
) : null
}
/>
</>
)}
@@ -580,7 +643,21 @@ export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { emb
return embedded ? content : null;
}
function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }: { logs: AgentEventLog[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; context: AgentLogContext; onClear: () => void; onCopied: (text: string) => void; onCopyBlocked: (text: string) => void }) {
function AgentLogView({
logs,
theme,
context,
onClear,
onCopied,
onCopyBlocked,
}: {
logs: AgentEventLog[];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
context: AgentLogContext;
onClear: () => void;
onCopied: (text: string) => void;
onCopyBlocked: (text: string) => void;
}) {
const [mode, setMode] = useState<"text" | "json">("text");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const content = mode === "text" ? formatLogText(logs, context) : formatLogJson(logs, context);
@@ -601,12 +678,28 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
<div className="text-base font-semibold leading-6"></div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Segmented size="small" value={mode} onChange={(value) => setMode(value as "text" | "json")} options={[{ label: "排查日志", value: "text" }, { label: "原始 JSON", value: "json" }]} />
<Segmented
size="small"
value={mode}
onChange={(value) => setMode(value as "text" | "json")}
options={[
{ label: "排查日志", value: "text" },
{ label: "原始 JSON", value: "json" },
]}
/>
<div className="flex items-center gap-2">
<span className="text-xs" style={{ color: theme.node.muted }}>{logs.length} </span>
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}></Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}></Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}></Button>
<span className="text-xs" style={{ color: theme.node.muted }}>
{logs.length}
</span>
<Button size="small" icon={<Copy className="size-3.5" />} onClick={() => void copy()}>
</Button>
<Button size="small" disabled={!lastError} onClick={() => lastError && void copy(formatLogText([lastError], context), "最近错误已复制")}>
</Button>
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} disabled={!logs.length} onClick={onClear}>
</Button>
</div>
</div>
<textarea
@@ -622,7 +715,29 @@ function AgentLogView({ logs, theme, context, onClear, onCopied, onCopyBlocked }
);
}
function AgentConnectView({ theme, url, token, enabled, connected, activity, connectError, onUrlChange, onTokenChange, onToggleEnabled }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; url: string; token: string; enabled: boolean; connected: boolean; activity: string; connectError: string; onUrlChange: (value: string) => void; onTokenChange: (value: string) => void; onToggleEnabled: () => void }) {
function AgentConnectView({
theme,
url,
token,
enabled,
connected,
activity,
connectError,
onUrlChange,
onTokenChange,
onToggleEnabled,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
url: string;
token: string;
enabled: boolean;
connected: boolean;
activity: string;
connectError: string;
onUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onToggleEnabled: () => void;
}) {
const { message } = App.useApp();
const statusText = connectError ? "连接失败" : connected ? activity : enabled ? "连接中" : "未连接";
const statusColor = connectError ? "#dc2626" : connected ? "#16a34a" : enabled ? "#d97706" : theme.node.muted;
@@ -632,7 +747,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
};
const codexPluginReminder = (
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<div className="font-medium" style={{ color: theme.node.text }}>Codex </div>
<div className="font-medium" style={{ color: theme.node.text }}>
Codex
</div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div>
<div className="mt-2 grid gap-1.5">
{[
@@ -640,7 +757,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
].map(([label, command]) => (
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>{label}</span>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>
{label}
</span>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
@@ -666,7 +785,9 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
<Fragment key={step.title}>
<div className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
{step.text}
</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
@@ -686,7 +807,10 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="shrink-0 text-sm font-medium leading-5"></span>
<span className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4" style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}>
<span
className="inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] leading-4"
style={{ borderColor: connected || enabled || connectError ? statusColor : theme.node.stroke, color: statusColor }}
>
<span className="size-1.5 shrink-0 rounded-full" style={{ background: statusColor }} />
<span className="truncate">{statusText}</span>
</span>
@@ -714,7 +838,13 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
Token
<span className="font-normal opacity-70">Connect token</span>
</span>
<Input.Password size="large" prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />} value={token} onChange={(event) => onTokenChange(event.target.value)} placeholder="自动发现,或手动填入 Connect token" />
<Input.Password
size="large"
prefix={<KeyRound className="mr-1 size-4" style={{ color: theme.node.faint }} />}
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="自动发现,或手动填入 Connect token"
/>
</label>
{connectError ? (
<div className="rounded-md border px-2.5 py-2 text-xs leading-5" style={{ borderColor: "rgba(220,38,38,.35)", color: "#dc2626" }}>
@@ -728,14 +858,38 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
);
}
function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loading, connected, onRefresh, onNewThread, onResumeThread, onDeleteThread }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; threads: AgentThreadSummary[]; activeThreadId: string; workspacePath: string; loading: boolean; connected: boolean; onRefresh: () => void; onNewThread: () => void; onResumeThread: (threadId: string) => void; onDeleteThread: (thread: AgentThreadSummary) => void }) {
function AgentHistoryView({
theme,
threads,
activeThreadId,
workspacePath,
loading,
connected,
onRefresh,
onNewThread,
onResumeThread,
onDeleteThread,
}: {
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
threads: AgentThreadSummary[];
activeThreadId: string;
workspacePath: string;
loading: boolean;
connected: boolean;
onRefresh: () => void;
onNewThread: () => void;
onResumeThread: (threadId: string) => void;
onDeleteThread: (thread: AgentThreadSummary) => void;
}) {
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="space-y-3">
<div className="flex min-w-0 items-center gap-2 text-xs" style={{ color: theme.node.muted }}>
<FolderOpen className="size-3.5 shrink-0" />
<span className="shrink-0"></span>
<span className="min-w-0 truncate" title={workspacePath}>{workspacePath || "默认画布目录"}</span>
<span className="min-w-0 truncate" title={workspacePath}>
{workspacePath || "默认画布目录"}
</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm" style={{ color: theme.node.muted }}>
@@ -758,7 +912,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
<div className="flex items-center gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-1.5">
{active ? <span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}></span> : null}
{active ? (
<span className="shrink-0 text-[10px] font-medium" style={{ color: theme.node.text }}>
</span>
) : null}
<div className="truncate text-sm font-medium leading-5">{thread.name || thread.preview || "未命名对话"}</div>
</div>
<div className="truncate text-[11px] leading-4 opacity-65">{thread.preview || thread.id}</div>
@@ -789,7 +947,11 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }) });
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }),
});
} catch {}
}
@@ -835,10 +997,12 @@ function formatLogText(logs: AgentEventLog[], context: AgentLogContext) {
`pendingTool: ${context.pendingTool ? toolName(context.pendingTool) : "none"}`,
`logs: ${logs.length}`,
].join("\n");
const body = logs.map((item, index) => {
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
}).join("\n\n---\n\n");
const body = logs
.map((item, index) => {
const detail = item.raw == null ? item.text : JSON.stringify(item.raw, null, 2);
return [`#${index + 1} ${item.time} ${item.title}`, detail].filter(Boolean).join("\n");
})
.join("\n\n---\n\n");
return [head, body || "暂无事件日志"].join("\n\n");
}
@@ -958,7 +1122,12 @@ function toolSummary(item?: AgentEventItem) {
function parseToolResult(result: unknown) {
const content = objectField(result, "content");
const text = Array.isArray(content) ? content.map((item) => objectField(item, "text")).filter((item): item is string => typeof item === "string").join("\n") : "";
const text = Array.isArray(content)
? content
.map((item) => objectField(item, "text"))
.filter((item): item is string => typeof item === "string")
.join("\n")
: "";
try {
return text ? JSON.parse(text) : result;
} catch {
@@ -9,7 +9,7 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasPromptChipInput } from "./canvas-prompt-chip-input";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
@@ -25,13 +25,14 @@ type CanvasNodePromptPanelProps = {
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
modeOverride?: CanvasNodeGenerationMode; // 插件节点用 useBuiltinPanel.mode 指定生成类型
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange, modeOverride }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = defaultMode(node.type);
const mode = modeOverride ?? defaultMode(node.type);
const config = buildNodeConfig(globalConfig, node, mode);
const hasTextContent = node.type === CanvasNodeType.Text && Boolean(node.metadata?.content?.trim());
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
@@ -62,13 +63,13 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
onPointerDown={(event) => event.stopPropagation()}
onWheel={(event) => event.stopPropagation()}
>
<CanvasResourceMentionTextarea
<CanvasPromptChipInput
value={prompt}
references={mentionReferences}
onChange={updatePrompt}
onSubmit={submit}
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
className="thin-scrollbar h-40 w-full cursor-text resize-none rounded-xl px-3 py-2 text-sm leading-5 outline-none"
style={{ background: "transparent", color: theme.node.text }}
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
/>
@@ -77,7 +78,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<CanvasPromptLibrary onSelect={updatePrompt} />
{mode === "image" ? (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasImageSettingsPopover
config={config}
placement="topLeft"
@@ -89,16 +90,16 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
</>
) : mode === "video" ? (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
</>
) : mode === "audio" ? (
<>
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
</>
) : (
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
)}
</div>
<Button
@@ -144,6 +145,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,
+51 -49
View File
@@ -26,7 +26,6 @@ type CanvasNodeProps = {
editRequestNonce?: number;
showPanel: boolean;
showImageInfo: boolean;
resourceLabel?: CanvasResourceReference;
mentionReferences?: CanvasResourceReference[];
pluginHost?: CanvasPluginHost;
registryVersion?: number;
@@ -41,6 +40,7 @@ type CanvasNodeProps = {
batchRecovering?: boolean;
batchMotion?: { x: number; y: number; index: number };
onMouseDown: (event: React.MouseEvent, nodeId: string) => void;
onSelectCapture?: (event: React.MouseEvent, nodeId: string) => void;
onHoverStart: (nodeId: string) => void;
onHoverEnd: (nodeId: string) => void;
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
@@ -88,7 +88,6 @@ export const CanvasNode = React.memo(function CanvasNode({
editRequestNonce = 0,
showPanel,
showImageInfo,
resourceLabel,
mentionReferences = [],
pluginHost,
renderPanel,
@@ -102,6 +101,7 @@ export const CanvasNode = React.memo(function CanvasNode({
batchRecovering = false,
batchMotion,
onMouseDown,
onSelectCapture,
onHoverStart,
onHoverEnd,
onConnectStart,
@@ -118,7 +118,7 @@ export const CanvasNode = React.memo(function CanvasNode({
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const definition = getNodeDefinition(data.type);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale) : null), [pluginHost, data, theme, scale]);
const pluginContext = useMemo<CanvasNodeContext | null>(() => (pluginHost ? buildNodeContext(pluginHost, data, theme, scale, isSelected) : null), [pluginHost, data, theme, scale, isSelected]);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
@@ -127,7 +127,14 @@ export const CanvasNode = React.memo(function CanvasNode({
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
// 支持「交互/移动」开关的节点:移动态(默认)内容不吃指针,拖动整块;交互态内容可操作。
// forceInteractive(如编辑态)强制可交互;空态(无内容)始终可交互,避免上传/生成按钮点不动。
const supportsInteractionToggle = Boolean(definition?.interactionToggle);
const forceInteractive = supportsInteractionToggle ? Boolean(definition?.forceInteractive?.(data)) : false;
const contentInteractive = !supportsInteractionToggle || forceInteractive || !data.metadata?.content ? true : Boolean(data.metadata?.interactive);
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
// 透明背景节点(如 SVG):卡片背景/边框透明,直接融入画布;选中/关联态仍显示描边以便定位
const transparentBg = Boolean(definition?.transparentBackground);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -301,47 +308,50 @@ export const CanvasNode = React.memo(function CanvasNode({
setHovered(false);
onHoverEnd(data.id);
}}
onMouseDownCapture={(event) => onSelectCapture?.(event, data.id)}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{isEditingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
maxLength={64}
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
style={{ borderColor: theme.node.muted, color: theme.node.text }}
onChange={(event) => setTitleDraft(event.target.value)}
onBlur={finishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") finishTitleEditing();
if (event.key === "Escape") {
setTitleDraft(data.title || "");
setIsEditingTitle(false);
}
}}
/>
) : (
<button
type="button"
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
style={{ color: theme.node.text }}
title="双击修改节点名称"
onDoubleClick={(event) => {
event.stopPropagation();
setIsEditingTitle(true);
}}
>
{data.title || "未命名节点"}
</button>
)}
</div>
{(isSelected || hovered || isEditingTitle) && (
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{isEditingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
maxLength={64}
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
style={{ borderColor: theme.node.muted, color: theme.node.text }}
onChange={(event) => setTitleDraft(event.target.value)}
onBlur={finishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") finishTitleEditing();
if (event.key === "Escape") {
setTitleDraft(data.title || "");
setIsEditingTitle(false);
}
}}
/>
) : (
<button
type="button"
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
style={{ color: theme.node.text }}
title="双击修改节点名称"
onDoubleClick={(event) => {
event.stopPropagation();
setIsEditingTitle(true);
}}
>
{data.title || "未命名节点"}
</button>
)}
</div>
)}
<div
className="relative h-full w-full overflow-visible rounded-3xl border-2"
style={{
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : transparentBg ? "transparent" : theme.node.stroke,
borderStyle: isGroup ? "dashed" : "solid",
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
@@ -370,7 +380,8 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: isGroup ? "transparent" : hasImageContent || hasVideoContent || transparentBg ? "transparent" : theme.node.fill,
pointerEvents: contentInteractive ? undefined : "none",
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -403,7 +414,6 @@ export const CanvasNode = React.memo(function CanvasNode({
</div>
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
{!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
@@ -416,7 +426,7 @@ export const CanvasNode = React.memo(function CanvasNode({
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={(definition?.hasSourceHandle ?? true) && data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[600px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
);
});
@@ -557,14 +567,6 @@ function TextContent({ node, theme, isEditingContent, textareaRef, mentionRefere
);
}
function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) {
return (
<span className={`pointer-events-none absolute right-2 top-2 z-30 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${reference.active ? "bg-[#2f80ff] text-white shadow-sm" : "bg-black/35 text-white/75"}`}>
{reference.label}
</span>
);
}
function ImageNodeContent(props: NodeContentRendererProps) {
if (!props.node.metadata?.content && props.isBatchRoot) {
const content =
@@ -4,7 +4,7 @@ import { AlertTriangle, Download, Puzzle, RefreshCw, Trash2 } from "lucide-react
import { canvasThemes } from "@/lib/canvas-theme";
import { installPluginFromUrl, setPluginEnabled, uninstallPlugin, updatePlugin } from "@/lib/canvas/plugin-loader";
import { fetchOfficialPlugins, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { fetchOfficialPlugins, hasUpgrade, type OfficialPluginEntry } from "@/lib/canvas/plugin-registry";
import { useThemeStore } from "@/stores/use-theme-store";
import { usePluginStore, type InstalledPlugin } from "@/stores/canvas/use-plugin-store";
@@ -81,12 +81,20 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
};
// 已安装插件的操作区:启用开关 +(非本地)更新/卸载
const installedControls = (record: InstalledPlugin) => (
// upgradable=true 时(远程有更高版本),更新按钮高亮为主色以提示升级
const installedControls = (record: InstalledPlugin, upgradable = false) => (
<>
<Switch size="small" checked={record.enabled} loading={busyId === record.id} onChange={(checked) => runOnPlugin(record, () => setPluginEnabled(record, checked), checked ? "已启用" : "已禁用")} />
{!record.local && (
<>
<Button type="text" size="small" icon={<RefreshCw className="size-4" />} loading={busyId === record.id} title="从来源更新" onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")} />
<Button
type={upgradable ? "primary" : "text"}
size="small"
icon={<RefreshCw className="size-4" />}
loading={busyId === record.id}
title={upgradable ? "有新版本,点击升级" : "从来源更新"}
onClick={() => runOnPlugin(record, async () => void (await updatePlugin(record)), "已更新")}
/>
<Popconfirm title="卸载该插件?" okText="卸载" cancelText="取消" onConfirm={() => uninstallPlugin(record.id)}>
<Button type="text" size="small" danger icon={<Trash2 className="size-4" />} title="卸载" />
</Popconfirm>
@@ -95,6 +103,15 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
</>
);
// 图标外挂一个绿点(右上角),用于「有可升级版本」的提示。
// boxShadow 画一圈与卡片同色的描边环,让绿点从图标上「浮起」。
const withUpgradeDot = (icon: ReactNode) => (
<span className="relative inline-flex">
{icon}
<span className="absolute -right-1 -top-1 size-2 rounded-full" style={{ background: "#22c55e", boxShadow: `0 0 0 2px ${theme.node.fill}` }} title="有新版本可升级" />
</span>
);
const versionTag = (version: string) => (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[10px]" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
v{version}
@@ -150,14 +167,18 @@ export function CanvasPluginManagerModal({ open, onClose }: { open: boolean; onC
<div className="thin-scrollbar max-h-[46vh] space-y-2 overflow-auto">
{official.map((entry) => {
const record = recordById.get(entry.id);
// 已安装且远程版本更高 → 显示绿点并高亮升级按钮
const upgradable = Boolean(record && hasUpgrade(record.version, entry.version));
const icon = entry.icon || <Puzzle className="size-4" />;
return row(
entry.id,
entry.icon || <Puzzle className="size-4" />,
upgradable ? withUpgradeDot(icon) : icon,
entry.name,
entry.version,
// 有升级时标题版本展示为「本地 → 远程」,让用户看清升级到哪个版本
upgradable && record ? `${record.version}${entry.version}` : entry.version,
entry.description,
record ? (
installedControls(record)
installedControls(record, upgradable)
) : (
<Button type="primary" size="small" icon={<Download className="size-4" />} loading={busyId === entry.id} onClick={() => handleInstallOfficial(entry)}>
@@ -0,0 +1,425 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { createPortal } from "react-dom";
import { Image } from "antd";
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { isImeComposing, isPlainEnterKey } from "@/lib/keyboard-event";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type Props = {
value: string;
references: CanvasResourceReference[];
onChange: (value: string) => void;
onSubmit?: () => void;
className?: string;
style?: CSSProperties;
placeholder?: string;
};
type MentionState = {
query: string;
rect: DOMRect | null;
};
type Token =
| { type: "text"; value: string }
| { type: "reference"; label: string };
// 提示词面板专用的 contentEditable 输入框:@ 引用图片时直接内嵌真实缩略图 chip,而不是「图片1」文字。
// 序列化时 chip → 引用 label 文本(如「图片1」),保证发给生成的 value 语义与旧 textarea 版一致。
export function CanvasPromptChipInput({ value, references, onChange, onSubmit, className, style, placeholder }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const editorRef = useRef<HTMLDivElement>(null);
const composingRef = useRef(false);
// 记录我们最近一次向父级 emit 的 value。聚焦时若 value 与它一致,说明是本组件输入的回声,
// 跳过重建以免打断光标 / IME;若不一致(如发送后父级把 prompt 清空、或从提示词库插入),即使聚焦也要重建。
const lastEmittedRef = useRef(value);
const [mention, setMention] = useState<MentionState | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const activeReferences = useMemo(() => references.filter((item) => item.active), [references]);
const referenceByLabel = useMemo(() => new Map(activeReferences.map((item) => [item.label, item])), [activeReferences]);
// 长 label 优先匹配,避免「图片1」把「图片10」切坏。
const activeLabels = useMemo(() => Array.from(new Set(activeReferences.map((item) => item.label))).sort((a, b) => b.length - a.length), [activeReferences]);
const tokens = useMemo(() => parseTokens(value, activeLabels), [value, activeLabels]);
const candidates = useMemo(() => {
if (!mention) return [];
const query = mention.query.trim().toLowerCase();
if (!query) return activeReferences;
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
}, [mention, activeReferences]);
// DOM ← value:未聚焦时按 value 重建;聚焦时仅当 value 是外部改动(非本组件回声)才重建。
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
if (document.activeElement === editor && value === lastEmittedRef.current) return;
editor.textContent = "";
tokens.forEach((token) => {
if (token.type === "text") {
editor.append(document.createTextNode(token.value));
return;
}
const reference = referenceByLabel.get(token.label);
if (reference) editor.append(createReferenceChip(reference, theme, setImagePreview));
else editor.append(document.createTextNode(token.label));
});
lastEmittedRef.current = value;
}, [tokens, referenceByLabel, theme, value]);
const emit = (next: string) => {
lastEmittedRef.current = next;
onChange(next);
};
const syncFromEditor = () => {
const editor = editorRef.current;
if (!editor) return;
emit(serializeEditor(editor));
syncMention();
};
const syncMention = () => {
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match || !activeReferences.length) {
closeMention();
return;
}
setMention({ query: match[1] || "", rect: caretRect() });
setActiveIndex(0);
};
const closeMention = () => {
setMention(null);
setActiveIndex(0);
};
const insertReference = (reference: CanvasResourceReference) => {
const editor = editorRef.current;
if (!editor) return;
removeActiveMention();
const chip = createReferenceChip(reference, theme, setImagePreview);
const space = document.createTextNode(" ");
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (range) {
range.insertNode(space);
range.insertNode(chip);
range.setStartAfter(space);
range.collapse(true);
selection?.removeAllRanges();
selection?.addRange(range);
} else {
editor.append(chip, space);
placeCaretAtEnd(editor);
}
closeMention();
emit(serializeEditor(editor));
};
const showPlaceholder = !value.trim();
return (
<div className="relative w-full">
{showPlaceholder && placeholder ? (
<div className="pointer-events-none absolute left-3 top-2 text-sm leading-5" style={{ color: theme.node.placeholder }}>
{placeholder}
</div>
) : null}
<div
ref={editorRef}
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
className={`${className || ""} overflow-y-auto whitespace-pre-wrap break-words outline-none`}
style={{ ...style, cursor: "text" }}
onInput={() => {
if (!composingRef.current) syncFromEditor();
}}
onCompositionStart={() => {
composingRef.current = true;
}}
onCompositionEnd={() => {
composingRef.current = false;
syncFromEditor();
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
event.stopPropagation();
if (isImeComposing(event)) return;
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((index) => (index + 1) % candidates.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
return;
}
if (event.key === "Enter") {
event.preventDefault();
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
return;
}
if (event.key === "Escape") {
event.preventDefault();
closeMention();
return;
}
}
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
event.preventDefault();
requestAnimationFrame(syncFromEditor);
return;
}
if (isPlainEnterKey(event) && onSubmit) {
event.preventDefault();
onSubmit();
return;
}
requestAnimationFrame(syncMention);
}}
onBlur={() => window.setTimeout(closeMention, 120)}
/>
{mention && candidates.length ? (
<MentionMenu rect={mention.rect} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} />
) : null}
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
</div>
);
}
function MentionMenu({ rect, references, activeIndex, theme, onSelect }: { rect: DOMRect | null; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
const selectedRef = useRef(false);
const activeItemRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
activeItemRef.current?.scrollIntoView({ block: "nearest" });
}, [activeIndex, references]);
const selectReference = (reference: CanvasResourceReference) => {
if (selectedRef.current) return;
selectedRef.current = true;
onSelect(reference);
};
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
const menuWidth = 256;
const maxMenuHeight = 224;
const gap = 6;
const anchor = rect || new DOMRect(16, 16, 0, 0);
const left = clamp(anchor.left, 8, window.innerWidth - menuWidth - 8);
const showAbove = anchor.bottom + gap + maxMenuHeight > window.innerHeight && anchor.top - gap - maxMenuHeight >= 0;
const top = showAbove ? anchor.top - gap - maxMenuHeight : anchor.bottom + gap;
return createPortal(
<div
data-canvas-resource-mention-menu="true"
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={stopCanvasInteraction}
onMouseDown={stopCanvasInteraction}
onClick={(event) => event.stopPropagation()}
>
{references.map((reference, index) => (
<button
key={reference.id}
ref={index === activeIndex ? activeItemRef : undefined}
type="button"
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
selectReference(reference);
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
selectReference(reference);
}}
>
<ReferencePreview reference={reference} />
<span className="min-w-0 flex-1">
<span className="block font-medium">{reference.label}</span>
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
</span>
</button>
))}
</div>,
document.body,
);
}
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
return (
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
<Icon className="size-4" />
</span>
);
}
function createReferenceChip(reference: CanvasResourceReference, theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
const wrapper = document.createElement("span");
wrapper.contentEditable = "false";
wrapper.dataset.refLabel = reference.label;
if (reference.kind === "image" && reference.previewUrl) {
const image = document.createElement("img");
image.src = reference.previewUrl;
image.alt = reference.title;
image.className = "size-6 rounded object-cover";
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
wrapper.appendChild(image);
wrapper.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
onImagePreview(reference.previewUrl || "");
});
} else {
wrapper.className = "mx-px inline-flex h-6 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
Object.assign(wrapper.style, { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text } as CSSProperties);
wrapper.title = reference.text || reference.title;
const text = document.createElement("span");
text.className = "block truncate";
text.textContent = reference.kind === "text" ? reference.text || reference.title : reference.label;
wrapper.appendChild(text);
}
return wrapper;
}
function serializeEditor(editor: HTMLElement) {
return serializeNodes(editor.childNodes).replace(//g, "");
}
function serializeNodes(nodes: NodeListOf<ChildNode>) {
let result = "";
nodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
if (!(node instanceof HTMLElement)) return;
const label = node.dataset.refLabel;
if (label) result += label;
else if (node.tagName === "BR") result += "\n";
else result += serializeNodes(node.childNodes);
});
return result;
}
function removeActiveMention() {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const text = textBeforeCaret();
const match = /@([^\s@]*)$/.exec(text);
if (!match) return;
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
range.deleteContents();
}
// chip 是 contentEditable="false" 的原子块,光标紧邻它按 Backspace/Delete 时整块删除。
function deleteAdjacentReference(key: string) {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.isCollapsed) return false;
const range = selection.getRangeAt(0);
const target = adjacentReferenceNode(range, key);
if (!target) return false;
const nextCaretNode = document.createTextNode("");
target.replaceWith(nextCaretNode);
range.setStart(nextCaretNode, 0);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
return true;
}
function adjacentReferenceNode(range: Range, key: string) {
const container = range.startContainer;
const offset = range.startOffset;
const previous = key === "Backspace";
if (container.nodeType === Node.TEXT_NODE) {
const text = container.textContent || "";
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
return findReferenceSibling(container, previous);
}
const children = Array.from(container.childNodes);
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
}
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
return current instanceof HTMLElement && current.dataset.refLabel ? current : null;
}
function textBeforeCaret() {
const selection = window.getSelection();
if (!selection?.rangeCount) return "";
const range = selection.getRangeAt(0).cloneRange();
const editor = closestEditor(range.startContainer);
if (!editor) return "";
range.setStart(editor, 0);
return range.toString();
}
function caretRect(): DOMRect | null {
const selection = window.getSelection();
if (!selection?.rangeCount) return null;
const range = selection.getRangeAt(0).cloneRange();
range.collapse(true);
const rect = range.getBoundingClientRect();
if (rect.width || rect.height || rect.left || rect.top) return rect;
// 空行/空编辑器时 range 无尺寸,退回到编辑器盒子。
const editor = closestEditor(range.startContainer);
return editor ? editor.getBoundingClientRect() : null;
}
function closestEditor(node: Node) {
const element = node instanceof Element ? node : node.parentElement;
return element?.closest("[contenteditable='true']") || null;
}
function placeCaretAtEnd(element: HTMLElement) {
const range = document.createRange();
range.selectNodeContents(element);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
// 按 active label(已按长度降序)把 value 文本切成「文本片段 + 命中的引用 label」。
function parseTokens(value: string, labels: string[]): Token[] {
if (!labels.length) return value ? [{ type: "text", value }] : [];
const escaped = labels.map(escapeRegExp).join("|");
const pattern = new RegExp(`(${escaped})`, "g");
const tokens: Token[] = [];
let lastIndex = 0;
for (const match of value.matchAll(pattern)) {
if (match.index === undefined) continue;
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
tokens.push({ type: "reference", label: match[0] });
lastIndex = match.index + match[0].length;
}
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
return tokens;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function clamp(value: number, min: number, max: number) {
if (max < min) return min;
return Math.min(Math.max(value, min), max);
}
@@ -0,0 +1,34 @@
export function CanvasRefreshShell() {
return (
<main className="relative h-full min-h-0 overflow-hidden bg-background text-foreground">
<div
className="absolute inset-0 opacity-60"
style={{
backgroundImage: "radial-gradient(circle, var(--border) 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
<div className="absolute bottom-5 left-1/2 z-50 flex h-14 -translate-x-1/2 items-center gap-1 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
{Array.from({ length: 7 }).map((_, index) => (
<div key={index} className="size-8 rounded-md bg-current opacity-10" />
))}
</div>
<div className="absolute bottom-24 left-6 z-50 h-40 w-[240px] rounded-lg border shadow-2xl backdrop-blur-sm" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="absolute left-7 top-7 h-5 w-12 rounded-sm bg-current opacity-10" />
<div className="absolute left-28 top-16 h-6 w-16 rounded-sm bg-current opacity-10" />
<div className="absolute bottom-7 left-16 h-8 w-20 rounded-sm bg-current opacity-10" />
<div className="absolute inset-5 rounded border border-current opacity-15" />
</div>
<div className="absolute bottom-5 left-5 z-50 flex h-14 w-[260px] items-center gap-2 rounded-xl border px-2 shadow-lg backdrop-blur" style={{ background: "var(--background)", borderColor: "var(--border)" }} aria-hidden="true">
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
<div className="h-1 flex-1 rounded-full bg-current opacity-10" />
<div className="h-4 w-10 rounded bg-current opacity-10" />
<div className="size-8 rounded-md bg-current opacity-10" />
</div>
</main>
);
}
@@ -89,9 +89,11 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
...(style || {}),
color: showOverlay ? "transparent" : style?.color,
caretColor: style?.color || theme.node.text,
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
cursor: "text",
// showOverlay 时高亮 div 覆盖在 textarea 上,若不把 textarea 提到上层,原生插入光标(caret)会被盖住看不见
...(showOverlay ? { position: "relative", zIndex: 1, background: "transparent", backgroundColor: "transparent" } : {}),
} as CSSProperties;
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} caretIndex={mention.start} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
return (
<div className={`relative h-full w-full ${containerClassName || ""}`}>
@@ -136,6 +138,18 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
onKeyDown?.(event);
return;
}
if ((event.key === "Backspace" || event.key === "Delete") && !mention) {
const el = textareaRef.current;
if (el && el.selectionStart === el.selectionEnd) {
const result = deleteAdjacentLabel(value, el.selectionStart, event.key === "Backspace" ? "backward" : "forward", activeLabels);
if (result) {
event.preventDefault();
updateValue(result.value, result.caret);
requestAnimationFrame(updateSelectionState);
return;
}
}
}
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -199,16 +213,24 @@ function MentionHighlightText({ value, labels, placeholder }: { value: string; l
);
}
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
function MentionMenu({ textarea, caretIndex, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; caretIndex: number; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
const selectedRef = useRef(false);
const rect = textarea.getBoundingClientRect();
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
const menuWidth = 256;
const maxMenuHeight = 224;
const gap = 6;
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
// 菜单锚定到 @ 所在的光标像素位置(而非 textarea 底边),避免输入框较高时菜单离 @ 太远。
// 画布可能被缩放,rect 是缩放后坐标,而镜像测量得到的是布局坐标,需按 scale 换算。
const scale = textarea.offsetWidth ? rect.width / textarea.offsetWidth : 1;
const computed = window.getComputedStyle(textarea);
const lineHeight = (parseFloat(computed.lineHeight) || parseFloat(computed.fontSize) * 1.4 || 20) * scale;
const caret = getCaretPoint(textarea, caretIndex);
const caretLeft = rect.left + (caret.left - textarea.scrollLeft) * scale;
const caretTop = rect.top + (caret.top - textarea.scrollTop) * scale;
const left = clamp(caretLeft, boundary.left + 8, boundary.right - menuWidth - 8);
const showAbove = caretTop + lineHeight + gap + maxMenuHeight > boundary.bottom && caretTop - gap - maxMenuHeight >= boundary.top;
const top = clamp(showAbove ? caretTop - gap - maxMenuHeight : caretTop + lineHeight + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
event.stopPropagation();
@@ -273,6 +295,58 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
// 通过镜像 div 复刻 textarea 的排版,测出第 index 个字符处光标相对 textarea 的像素坐标(布局尺度,未含缩放)。
const MIRROR_STYLE_PROPS = ["boxSizing", "width", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft", "borderTopWidth", "borderRightWidth", "borderBottomWidth", "borderLeftWidth", "fontStyle", "fontVariant", "fontWeight", "fontStretch", "fontSize", "lineHeight", "fontFamily", "textAlign", "textIndent", "letterSpacing", "wordSpacing", "tabSize", "textTransform"] as const;
function getCaretPoint(textarea: HTMLTextAreaElement, index: number) {
const computed = window.getComputedStyle(textarea);
const mirror = document.createElement("div");
const style = mirror.style;
style.position = "absolute";
style.top = "0";
style.left = "-9999px";
style.visibility = "hidden";
style.whiteSpace = "pre-wrap";
style.overflowWrap = "break-word";
for (const prop of MIRROR_STYLE_PROPS) style[prop] = computed[prop];
mirror.textContent = textarea.value.slice(0, index);
const marker = document.createElement("span");
marker.textContent = textarea.value.slice(index) || ".";
mirror.appendChild(marker);
document.body.appendChild(mirror);
const point = { left: marker.offsetLeft, top: marker.offsetTop };
document.body.removeChild(mirror);
return point;
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// 纯 textarea 无法做真正的原子 token,这里在删除时把整个引用 label 当作一个整体一次删掉,
// 避免逐字删除把「图片1」删成「图片」。labels 需按长度降序传入以优先匹配更长的 label。
function deleteAdjacentLabel(value: string, caret: number, direction: "backward" | "forward", labels: string[]): { value: string; caret: number } | null {
if (direction === "backward") {
const before = value.slice(0, caret);
for (const label of labels) {
if (!label) continue;
const match = new RegExp(`(^|\\s)(${escapeRegExp(label)})\\s*$`).exec(before);
if (match) {
const start = match.index + match[1].length; // label 起始位置(保留前面的分隔空格)
return { value: value.slice(0, start) + value.slice(caret), caret: start };
}
}
} else {
// 向后删除:光标前须是行首或空白,避免切进其它文字中间
if (caret > 0 && !/\s/.test(value[caret - 1])) return null;
const after = value.slice(caret);
for (const label of labels) {
if (!label) continue;
const match = new RegExp(`^(\\s*)(${escapeRegExp(label)})(?=\\s|$)`).exec(after);
if (match) {
return { value: value.slice(0, caret) + value.slice(caret + match[0].length), caret };
}
}
}
return null;
}
+226 -25
View File
@@ -1,16 +1,29 @@
import { useMemo, useState } from "react";
import { Empty, Input, Select, Tag } from "antd";
import { ChevronRight, FileText, Image as ImageIcon, Music2, Search, Settings2, Square, Type, Video } from "lucide-react";
import { useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { App, Empty, Input, Popconfirm, Select, Tag } from "antd";
import { Check, ChevronRight, Download, FileText, Image as ImageIcon, ListChecks, Music2, Plus, Search, Settings2, Square, Trash2, Type, Video } from "lucide-react";
import { motion } from "motion/react";
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
import { exportCanvasNodes } from "@/lib/canvas/canvas-export";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { cn } from "@/lib/utils";
import { uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { useAssetStore, type Asset, type AssetKind } from "@/stores/use-asset-store";
import {
CANVAS_SIDE_PANEL_MAX_WIDTH,
CANVAS_SIDE_PANEL_MIN_WIDTH,
CANVAS_SIDE_PANEL_MOTION_MS,
useCanvasSidePanelStore,
} from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
import type { InsertAssetPayload } from "./asset-picker-modal";
const PANEL_MOTION_SECONDS = CANVAS_SIDE_PANEL_MOTION_MS / 1000;
const PANEL_EASE = [0.22, 1, 0.36, 1] as const;
type PanelTab = "canvas" | "assets";
type Props = {
@@ -39,22 +52,67 @@ const STATUS_COLOR: Record<string, string> = {
export function CanvasSidePanel({ nodes, selectedNodeIds, onFocusNode, onInsertAsset }: Props) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [tab, setTab] = useState<PanelTab>("canvas");
const width = useCanvasSidePanelStore((state) => state.width);
const panelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
const panelMounted = useCanvasSidePanelStore((state) => state.panelMounted);
const panelClosing = useCanvasSidePanelStore((state) => state.panelClosing);
const setWidth = useCanvasSidePanelStore((state) => state.setWidth);
const [resizing, setResizing] = useState(false);
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, startWidth + moveEvent.clientX - startX));
setWidth(nextWidth);
};
const onUp = () => {
localStorage.setItem("canvas-side-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
if (!panelMounted) return null;
return (
<aside className="flex h-full w-[280px] shrink-0 flex-col overflow-hidden border-r" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }} data-canvas-no-zoom>
<div className="flex items-center gap-1 px-3 pt-3">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
</aside>
<motion.div
className="relative z-[60] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col overflow-hidden border-r"
initial={{ x: -48 }}
animate={{ x: panelClosing ? -28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: PANEL_EASE }}
style={{ width, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
data-canvas-no-zoom
>
<div className="flex items-center gap-5 px-4 pt-3.5">
<TabButton label="画布" active={tab === "canvas"} theme={theme} onClick={() => setTab("canvas")} />
<TabButton label="资产" active={tab === "assets"} theme={theme} onClick={() => setTab("assets")} />
</div>
<div className="mt-2 min-h-0 flex-1 overflow-hidden">{tab === "canvas" ? <CanvasNodesTab nodes={nodes} selectedNodeIds={selectedNodeIds} onFocusNode={onFocusNode} theme={theme} /> : <CanvasAssetsTab onInsert={onInsertAsset} theme={theme} />}</div>
<button type="button" className="absolute inset-y-0 right-0 z-40 w-4 translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整左侧面板宽度" />
</motion.aside>
</motion.div>
);
}
function TabButton({ label, active, theme, onClick }: { label: string; active: boolean; theme: CanvasTheme; onClick: () => void }) {
return (
<button type="button" onClick={onClick} className={cn("rounded-lg px-3 py-1.5 text-sm font-semibold transition", active ? "" : "opacity-55 hover:opacity-90")} style={active ? { background: theme.toolbar.activeBg, color: theme.toolbar.activeText } : { color: theme.node.text }}>
<button type="button" onClick={onClick} className="relative pb-1.5 text-sm font-semibold transition-opacity" style={{ color: theme.node.text, opacity: active ? 1 : 0.45 }}>
{label}
{active ? <motion.span layoutId="sidePanelTabIndicator" className="absolute inset-x-0 -bottom-px h-0.5 rounded-full" style={{ background: theme.toolbar.activeText }} transition={{ type: "spring", stiffness: 500, damping: 34 }} /> : null}
</button>
);
}
@@ -79,20 +137,64 @@ function nodePreviewText(node: CanvasNodeData) {
}
function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes: CanvasNodeData[]; selectedNodeIds: Set<string>; onFocusNode: (nodeId: string) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const [keyword, setKeyword] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [selectMode, setSelectMode] = useState(false);
const [checked, setChecked] = useState<Set<string>>(new Set());
const [exporting, setExporting] = useState(false);
const filtered = useMemo(() => {
const query = keyword.trim().toLowerCase();
return nodes.filter((node) => (typeFilter === "all" || node.type === typeFilter) && (!query || [node.title, node.metadata?.content, node.metadata?.prompt].filter(Boolean).join(" ").toLowerCase().includes(query)));
}, [nodes, keyword, typeFilter]);
const exitSelect = () => {
setSelectMode(false);
setChecked(new Set());
};
const toggleChecked = (id: string) =>
setChecked((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const allChecked = filtered.length > 0 && filtered.every((node) => checked.has(node.id));
const toggleAll = () => setChecked(allChecked ? new Set() : new Set(filtered.map((node) => node.id)));
const handleExport = async () => {
const targets = nodes.filter((node) => checked.has(node.id));
if (!targets.length) return;
setExporting(true);
const hide = message.loading("正在导出选中元素…", 0);
try {
await exportCanvasNodes(targets, `画布元素-${targets.length}`);
message.success(`已导出 ${targets.length} 个元素`);
exitSelect();
} catch (error) {
console.error(error);
message.error("导出失败,请重试");
} finally {
hide();
setExporting(false);
}
};
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 px-3 pb-2.5 pt-1">
<span className="text-xs font-medium opacity-60"></span>
{filtered.length ? <span className="text-xs opacity-35">{filtered.length}</span> : null}
<Select size="small" variant="borderless" className="ml-auto w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />
<button
type="button"
onClick={() => (selectMode ? exitSelect() : setSelectMode(true))}
className="ml-auto flex items-center gap-1 rounded-md px-1.5 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
style={selectMode ? { color: theme.toolbar.activeText, opacity: 1 } : undefined}
>
<ListChecks className="size-3.5" />
{selectMode ? "取消" : "选择"}
</button>
{selectMode ? null : <Select size="small" variant="borderless" className="w-20" value={typeFilter} onChange={setTypeFilter} options={NODE_FILTER_OPTIONS} />}
</div>
<div className="px-3 pb-2.5">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索节点" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
@@ -103,17 +205,19 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
{filtered.map((node) => {
const Icon = NODE_TYPE_ICON[node.type] || FileText;
const isImage = node.type === CanvasNodeType.Image && node.metadata?.content;
const active = selectedNodeIds.has(node.id);
const isChecked = checked.has(node.id);
const active = selectMode ? isChecked : selectedNodeIds.has(node.id);
return (
<button
key={node.id}
type="button"
onClick={() => onFocusNode(node.id)}
onClick={() => (selectMode ? toggleChecked(node.id) : onFocusNode(node.id))}
className={cn("flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition", active ? "" : "hover:bg-black/5 dark:hover:bg-white/5")}
style={active ? { background: theme.toolbar.activeBg } : undefined}
>
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md" style={{ background: theme.node.fill }}>
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-4.5 opacity-70" />}
{selectMode ? <CheckMark checked={isChecked} theme={theme} /> : null}
<span className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-md">
{isImage ? <img src={node.metadata!.content} alt={node.title} className="size-full object-cover" /> : <Icon className="size-5 opacity-60" />}
</span>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate text-sm font-medium leading-snug">{node.title || getNodeDefinition(node.type)?.title || "未命名节点"}</span>
@@ -128,10 +232,36 @@ function CanvasNodesTab({ nodes, selectedNodeIds, onFocusNode, theme }: { nodes:
<div className="pt-16 text-center text-sm opacity-40"></div>
)}
</div>
{selectMode ? (
<div className="flex items-center gap-2 border-t px-3 py-2.5" style={{ borderColor: theme.toolbar.border }}>
<button type="button" onClick={toggleAll} className="rounded-md px-2 py-1 text-xs font-medium opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10">
{allChecked ? "取消全选" : "全选"}
</button>
<span className="text-xs opacity-45"> {checked.size}</span>
<button
type="button"
onClick={() => void handleExport()}
disabled={!checked.size || exporting}
className="ml-auto flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
<Download className="size-3.5" />
</button>
</div>
) : null}
</div>
);
}
function CheckMark({ checked, theme }: { checked: boolean; theme: CanvasTheme }) {
return (
<span className="grid size-4 shrink-0 place-items-center rounded border transition" style={{ borderColor: checked ? theme.toolbar.activeText : theme.node.stroke, background: checked ? theme.toolbar.activeText : "transparent" }}>
{checked ? <Check className="size-3 text-white" /> : null}
</span>
);
}
// ---------------------------------------------------------------------------
// 资产 Tab —— 按类型折叠分组 + 标签筛选,点击插入画布
// ---------------------------------------------------------------------------
@@ -149,10 +279,15 @@ function buildInsertPayload(asset: Asset): InsertAssetPayload {
}
function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetPayload) => void; theme: CanvasTheme }) {
const { message } = App.useApp();
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const removeAsset = useAssetStore((state) => state.removeAsset);
const [keyword, setKeyword] = useState("");
const [tagFilter, setTagFilter] = useState<string>("all");
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [uploading, setUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const allTags = useMemo(() => Array.from(new Set(assets.flatMap((asset) => asset.tags || []))).slice(0, 20), [assets]);
@@ -163,10 +298,51 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
const groups = useMemo(() => ASSET_GROUPS.map((group) => ({ ...group, items: filtered.filter((asset) => asset.kind === group.kind) })).filter((group) => group.items.length > 0), [filtered]);
const handleFiles = async (fileList: FileList | null) => {
const files = Array.from(fileList || []);
if (!files.length) return;
setUploading(true);
const hide = message.loading("正在添加资产…", 0);
let added = 0;
try {
for (const file of files) {
if (file.type.startsWith("image/")) {
const image = await uploadImage(file);
addAsset({ kind: "image", title: file.name || "图片", coverUrl: image.url, tags: [], data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType } });
added += 1;
} else if (file.type.startsWith("video/")) {
const media = await uploadMediaFile(file, "video");
addAsset({ kind: "video", title: file.name || "视频", coverUrl: "", tags: [], data: { url: media.url, storageKey: media.storageKey, width: media.width || 0, height: media.height || 0, bytes: media.bytes, mimeType: media.mimeType } });
added += 1;
}
}
if (added) message.success(`已添加 ${added} 个资产`);
else message.warning("仅支持图片或视频文件");
} catch (error) {
console.error(error);
message.error("添加失败,请重试");
} finally {
hide();
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = "";
}
};
return (
<div className="flex h-full flex-col">
<div className="px-3 pb-2">
<div className="flex items-center gap-2 px-3 pb-2 pt-1">
<Input size="small" allowClear prefix={<Search className="size-3.5 text-stone-400" />} placeholder="搜索资产" value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<button
type="button"
disabled={uploading}
onClick={() => fileInputRef.current?.click()}
className="flex shrink-0 items-center gap-1 rounded-md px-2 py-1 text-xs font-semibold transition hover:bg-black/5 disabled:cursor-not-allowed disabled:opacity-50 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
<Plus className="size-3.5" />
</button>
<input ref={fileInputRef} type="file" accept="image/*,video/*" multiple className="hidden" onChange={(e) => void handleFiles(e.target.files)} />
</div>
{allTags.length ? (
<div className="flex flex-wrap gap-1.5 px-3 pb-2">
@@ -196,7 +372,7 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
{isCollapsed ? null : (
<div className="grid grid-cols-2 gap-2 px-1 pb-2 pt-1">
{group.items.map((asset) => (
<AssetCard key={asset.id} asset={asset} theme={theme} onClick={() => onInsert(buildInsertPayload(asset))} />
<AssetCard key={asset.id} asset={asset} theme={theme} onInsert={() => onInsert(buildInsertPayload(asset))} onRemove={() => (removeAsset(asset.id), message.success("资产已移除"))} />
))}
</div>
)}
@@ -212,13 +388,38 @@ function CanvasAssetsTab({ onInsert, theme }: { onInsert: (payload: InsertAssetP
);
}
function AssetCard({ asset, theme, onClick }: { asset: Asset; theme: CanvasTheme; onClick: () => void }) {
const cover = asset.coverUrl || (asset.kind === "image" ? asset.data.dataUrl : "");
function AssetCard({ asset, theme, onInsert, onRemove }: { asset: Asset; theme: CanvasTheme; onInsert: () => void; onRemove: () => void }) {
return (
<button type="button" onClick={onClick} className="group relative overflow-hidden rounded-lg border text-left transition hover:shadow-md" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
{cover ? <img src={cover} alt={asset.title} className="aspect-square w-full object-cover" /> : <div className="flex aspect-square items-center justify-center p-2 text-center text-[11px] leading-4 opacity-60">{asset.title}</div>}
<div className="truncate px-2 py-1.5 text-[11px] font-medium">{asset.title}</div>
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-xs font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100"></div>
</button>
<div className="group relative aspect-square overflow-hidden rounded-xl border transition duration-200 hover:-translate-y-0.5 hover:shadow-lg" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
<AssetCover asset={asset} />
<div className="absolute inset-0 flex items-center justify-center gap-2.5 opacity-0 transition duration-200 group-hover:opacity-100">
<button
type="button"
onClick={onInsert}
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-stone-900 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80"
aria-label="插入画布"
>
<Plus className="size-4" />
</button>
<Popconfirm title="移除该资产?" okText="移除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onRemove}>
<button
type="button"
className="grid size-8 place-items-center rounded-full bg-white/90 text-stone-700 shadow-sm backdrop-blur transition hover:bg-white hover:text-red-500 dark:bg-black/60 dark:text-stone-100 dark:hover:bg-black/80 dark:hover:text-red-400"
aria-label="移除资产"
>
<Trash2 className="size-4" />
</button>
</Popconfirm>
</div>
</div>
);
}
function AssetCover({ asset }: { asset: Asset }) {
if (asset.kind === "text") return <div className="size-full overflow-hidden whitespace-pre-wrap break-words p-2.5 text-[11px] leading-snug opacity-80">{asset.data.content}</div>;
if (asset.kind === "video") {
if (asset.coverUrl) return <img src={asset.coverUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
return <video src={`${asset.data.url}#t=0.1`} muted playsInline preload="metadata" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
}
return <img src={asset.coverUrl || asset.data.dataUrl} alt="" className="size-full object-cover transition duration-300 group-hover:scale-[1.04]" />;
}
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from "react";
import { BookOpen, Bot, Download, Home, Images, Menu, PanelLeftClose, PanelLeftOpen, Plus, Redo2, Trash2, Undo2, Upload } from "lucide-react";
import { Button, Dropdown, Modal, Tooltip } from "antd";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { canvasThemes } from "@/lib/canvas-theme";
import { useCanvasSidePanelStore } from "@/stores/use-canvas-side-panel-store";
import { useThemeStore } from "@/stores/use-theme-store";
import { DOCS_URL } from "@/constant/env";
export function CanvasTopBar({
title,
titleDraft,
isTitleEditing,
onTitleDraftChange,
onStartTitleEditing,
onFinishTitleEditing,
onCancelTitleEditing,
canUndo,
canRedo,
onHome,
onProjects,
onCreateProject,
onDeleteProject,
onExportProject,
onImportImage,
onOpenPlugins,
onUndo,
onRedo,
agentOpen,
compactAgentStatus,
onToggleAgent,
}: {
title: string;
titleDraft: string;
isTitleEditing: boolean;
onTitleDraftChange: (value: string) => void;
onStartTitleEditing: () => void;
onFinishTitleEditing: () => void;
onCancelTitleEditing: () => void;
canUndo: boolean;
canRedo: boolean;
onHome: () => void;
onProjects: () => void;
onCreateProject: () => void;
onDeleteProject: () => void;
onExportProject: () => void;
onImportImage: () => void;
onOpenPlugins: () => void;
onUndo: () => void;
onRedo: () => void;
agentOpen: boolean;
compactAgentStatus: { connected: boolean; enabled: boolean; activity: string };
onToggleAgent: () => void;
}) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const titleRef = useRef<HTMLDivElement>(null);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const sidePanelOpen = useCanvasSidePanelStore((state) => state.panelOpen);
const toggleSidePanel = useCanvasSidePanelStore((state) => state.togglePanel);
useEffect(() => {
if (!isTitleEditing) return;
const close = (event: PointerEvent) => {
if (!titleRef.current?.contains(event.target as Node)) onFinishTitleEditing();
};
document.addEventListener("pointerdown", close, true);
return () => document.removeEventListener("pointerdown", close, true);
}, [isTitleEditing, onFinishTitleEditing]);
return (
<>
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between pl-1 pr-4">
<div className="pointer-events-auto flex min-w-0 items-center gap-2">
<Tooltip title={sidePanelOpen ? "收起面板" : "展开面板"}>
<button
type="button"
onClick={toggleSidePanel}
aria-label={sidePanelOpen ? "收起面板" : "展开面板"}
className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10"
style={{ color: theme.node.text }}
>
{sidePanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
</button>
</Tooltip>
<Dropdown
trigger={["click"]}
menu={{
items: [
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
{ type: "divider" },
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
{ key: "delete", danger: true, icon: <Trash2 className="size-4" />, label: "删除当前画布", onClick: onDeleteProject },
{ type: "divider" },
{ key: "import", icon: <Upload className="size-4" />, label: "导入资产", onClick: onImportImage },
{ key: "export", icon: <Download className="size-4" />, label: "导出当前画布", onClick: onExportProject },
{ type: "divider" },
{ key: "undo", disabled: !canUndo, icon: <Undo2 className="size-4" />, label: <MenuLabel text="撤销" shortcut="⌘ Z" />, onClick: onUndo },
{ key: "redo", disabled: !canRedo, icon: <Redo2 className="size-4" />, label: <MenuLabel text="重做" shortcut="⌘ ⇧ Z / ⌘ Y" />, onClick: onRedo },
],
}}
>
<button type="button" className="grid size-7 place-items-center rounded-full transition hover:bg-black/5 dark:hover:bg-white/10" style={{ color: theme.node.text }} aria-label="打开画布菜单">
<Menu className="size-4" />
</button>
</Dropdown>
<div ref={titleRef} className="flex min-w-0 items-center gap-2">
{isTitleEditing ? (
<input
autoFocus
value={titleDraft}
onChange={(event) => onTitleDraftChange(event.target.value)}
onBlur={onFinishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") onFinishTitleEditing();
if (event.key === "Escape") onCancelTitleEditing();
}}
className="max-w-[280px] bg-transparent p-0 text-left text-lg font-semibold tracking-normal outline-none"
style={{ color: theme.node.text }}
/>
) : (
<button
type="button"
className="max-w-[280px] truncate border-b border-dashed border-transparent text-left text-lg font-semibold tracking-normal transition hover:border-current"
onDoubleClick={onStartTitleEditing}
title="双击修改画布名称"
>
{title}
</button>
)}
</div>
<CompactAgentStatus status={compactAgentStatus} onClick={onToggleAgent} />
</div>
<div className="pointer-events-auto flex items-center gap-1.5">
<UserStatusActions variant="canvas" onOpenShortcuts={() => setShortcutsOpen(true)} onOpenPlugins={onOpenPlugins} />
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
<Button
type="text"
className="!h-10 !rounded-xl !px-3 !font-medium"
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
icon={<Bot className="size-4" />}
onClick={onToggleAgent}
>
Agent
</Button>
</div>
</div>
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
<div className="space-y-2 border-t pt-4 text-sm" style={{ borderColor: theme.node.stroke }}>
<Shortcut keys={["拖动画布"]} value="平移视图" />
<Shortcut keys={["滚轮"]} value="缩放画布" />
<Shortcut keys={["缩放滑杆"]} value="精确调整缩放" />
<Shortcut keys={["Ctrl / Cmd", "拖动"]} value="框选多个节点" />
<Shortcut keys={["Shift / Ctrl / Cmd", "点击"]} value="追加选择节点" />
<Shortcut keys={["Ctrl / Cmd", "A"]} value="全选节点" />
<Shortcut keys={["Ctrl / Cmd", "C / V"]} value="复制 / 粘贴节点,或粘贴剪切板文本/图片" />
<Shortcut keys={["Ctrl / Cmd", "Z"]} value="撤销" />
<Shortcut keys={["Ctrl / Cmd", "Shift", "Z"]} value="重做" />
<Shortcut keys={["Ctrl / Cmd", "Y"]} value="重做" />
<Shortcut keys={["Delete / Backspace"]} value="删除选中" />
<Shortcut keys={["Esc"]} value="取消选择并关闭浮层" />
<Shortcut keys={["拖入图片/视频/音频"]} value="上传到画布" />
</div>
</Modal>
</>
);
}
function MenuLabel({ text, shortcut }: { text: string; shortcut: string }) {
return (
<span className="flex min-w-36 items-center justify-between gap-8">
<span>{text}</span>
<span className="text-xs opacity-45">{shortcut}</span>
</span>
);
}
function CompactAgentStatus({ status, onClick }: { status: { connected: boolean; enabled: boolean; activity: string }; onClick: () => void }) {
const colorTheme = useThemeStore((state) => state.theme);
const theme = canvasThemes[colorTheme];
const label = status.connected ? "Codex 已连接" : status.enabled ? `Codex ${status.activity || "连接中"}` : "Codex 未连接";
const dotColor = status.connected ? "#22c55e" : status.enabled ? "#f59e0b" : theme.node.muted;
return (
<button type="button" className="flex h-8 items-center gap-1.5 text-xs transition hover:opacity-75" style={{ color: status.connected ? "#16a34a" : status.enabled ? "#d97706" : theme.node.muted }} onClick={onClick} title="打开本地 Codex 面板">
<span className="size-2 rounded-full" style={{ background: dotColor }} />
<span className="max-w-[140px] truncate">{label}</span>
</button>
);
}
function Shortcut({ keys, value }: { keys: string[]; value: string }) {
return (
<div className="grid grid-cols-[minmax(0,1fr)_120px] items-center gap-6 rounded-lg px-1 py-1.5">
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
{keys.map((key, index) => (
<span key={`${key}-${index}`} className="flex items-center gap-1.5">
{index ? <span className="text-xs opacity-35">+</span> : null}
<kbd
className="min-w-9 rounded-md border px-2.5 py-1.5 text-center text-xs font-medium leading-none shadow-[inset_0_-1px_0_rgba(0,0,0,.08),0_1px_2px_rgba(0,0,0,.06)]"
style={{ borderColor: "rgba(120,113,108,.28)", background: "linear-gradient(#fff, rgba(245,245,244,.92))", color: "rgb(68,64,60)" }}
>
{key}
</kbd>
</span>
))}
</span>
<span className="text-right text-sm opacity-55">{value}</span>
</div>
);
}
@@ -152,7 +152,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
onCanvasDeselect?.();
}
panState.current.isPanning = false;
document.body.style.cursor = "default";
document.body.style.cursor = "";
};
window.addEventListener("pointermove", handlePointerMove);
+13 -1
View File
@@ -33,7 +33,7 @@ export const imageAspectOptions = aspectOptions.map((item) => ({ value: item.siz
type ImageSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: "quality" | "size" | "count", value: string) => void;
onConfigChange: (key: "quality" | "size" | "count" | "background", value: string) => void;
theme: CanvasTheme;
showTitle?: boolean;
className?: string;
@@ -46,6 +46,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
const quality = config.quality || "auto";
const count = Math.max(1, Math.min(maxCount, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const transparentBackground = config.background === "transparent";
const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize);
const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]);
const selectAspect = (value: string) => {
@@ -117,6 +118,17 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
))}
</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="space-y-0.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="text-xs" style={{ color: theme.node.muted, opacity: 0.75 }}>
()
</div>
</div>
<span onMouseDown={(event) => event.stopPropagation()}>
<Switch size="small" checked={transparentBackground} onChange={(checked) => onConfigChange("background", checked ? "transparent" : "")} />
</span>
</div>
<div className="space-y-2.5">
<SettingTitle color={theme.node.muted}></SettingTitle>
<div className="grid grid-cols-4 gap-2.5">
@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import { trackPageview } from "@/lib/analytics";
// 监听 SPA 路由变化并上报 pageview。无统计配置时 trackPageview 为空操作。
export function AnalyticsTracker() {
const location = useLocation();
useEffect(() => {
trackPageview(`${location.pathname}${location.search}`);
}, [location.pathname, location.search]);
return null;
}
+30
View File
@@ -0,0 +1,30 @@
// 运行期配置读取层。
// 优先级:window.__RUNTIME_CONFIG__(容器启动时由 entrypoint 注入)> 构建期 VITE_ 变量 > 默认值。
// 这样既支持「同一镜像 docker run -e 配置」,也兼容自行 build 时的构建期注入。
//
// 统计按「每家一个独立变量」配置:填了谁就启用谁,可同时启用多家,默认全空即关闭。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本/内联 JS。
type RuntimeConfig = {
ANALYTICS_GA4_ID?: string; // GA4 衡量 IDG-XXXX
ANALYTICS_BAIDU_ID?: string; // 百度统计站点 ID
};
declare global {
interface Window {
__RUNTIME_CONFIG__?: RuntimeConfig;
}
}
const runtime: RuntimeConfig = (typeof window !== "undefined" && window.__RUNTIME_CONFIG__) || {};
function read(key: keyof RuntimeConfig, buildTime: string | undefined, fallback = ""): string {
const value = runtime[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof buildTime === "string" && buildTime.trim()) return buildTime.trim();
return fallback;
}
export const ANALYTICS_GA4_ID = read("ANALYTICS_GA4_ID", import.meta.env.VITE_ANALYTICS_GA4_ID);
export const ANALYTICS_BAIDU_ID = read("ANALYTICS_BAIDU_ID", import.meta.env.VITE_ANALYTICS_BAIDU_ID);
+85
View File
@@ -0,0 +1,85 @@
// 统计分析加载器:默认关闭,可同时启用多家。
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接——
// 不接受任意脚本 URL / 内联 JS,避免「配置项被用来在访客浏览器执行任意代码」。
// 全空时不注入任何脚本、不发任何外部请求。这是开源项目的硬要求:
// fork/自托管者默认零统计,官方站点仅通过环境变量注入自己的 ID(ID 不入库)。
import { ANALYTICS_BAIDU_ID, ANALYTICS_GA4_ID } from "@/constant/runtime-config";
type GtagFn = (...args: unknown[]) => void;
declare global {
interface Window {
dataLayer?: unknown[];
gtag?: GtagFn;
_hmt?: unknown[][];
}
}
let initialized = false;
// 记录实际启用了哪些统计,供路由上报时按需分发。
const active = { ga4: false, baidu: false };
function appendScript(src: string, attrs: Record<string, string> = {}) {
const el = document.createElement("script");
el.async = true;
el.src = src;
for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
document.head.appendChild(el);
return el;
}
function initGa4(id: string) {
window.dataLayer = window.dataLayer || [];
const gtag: GtagFn = (...args) => {
window.dataLayer!.push(args);
};
window.gtag = gtag;
appendScript(`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(id)}`);
gtag("js", new Date());
// SPA 路由上报交给 trackPageview,这里关闭默认的自动 page_view,避免重复。
gtag("config", id, { send_page_view: false });
active.ga4 = true;
}
function initBaidu(id: string) {
window._hmt = window._hmt || [];
appendScript(`https://hm.baidu.com/hm.js?${encodeURIComponent(id)}`);
active.baidu = true;
}
export function initAnalytics() {
if (initialized || typeof window === "undefined") return;
initialized = true;
// 各家相互独立,逐个判断并启用;任一家出错都不影响其它家与主应用。
if (ANALYTICS_GA4_ID) {
try {
initGa4(ANALYTICS_GA4_ID);
} catch {
/* 忽略 */
}
}
if (ANALYTICS_BAIDU_ID) {
try {
initBaidu(ANALYTICS_BAIDU_ID);
} catch {
/* 忽略 */
}
}
}
// SPA 路由切换时上报页面浏览,分发给所有已启用的统计。
export function trackPageview(path: string) {
try {
if (active.ga4 && window.gtag) {
window.gtag("event", "page_view", { page_path: path, page_location: window.location.href });
}
if (active.baidu && window._hmt) {
window._hmt.push(["_trackPageview", path]);
}
} catch {
/* 忽略 */
}
}
+37
View File
@@ -5,6 +5,7 @@ import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
const zipFiles: { name: string; data: BlobPart }[] = [];
@@ -29,6 +30,39 @@ export async function exportCanvasProjects(projects: CanvasProject[], fileName =
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
export async function exportCanvasNodes(nodes: CanvasNodeData[], fileName = "画布元素") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const used = new Set<string>();
const uniqueName = (base: string, ext: string) => {
const safe = safeFileName(base) || "元素";
let name = `${safe}.${ext}`;
for (let i = 1; used.has(name); i += 1) name = `${safe}-${i}.${ext}`;
used.add(name);
return name;
};
await Promise.all(
nodes.map(async (node) => {
const title = node.title || node.type;
const storageKey = node.metadata?.storageKey || "";
if (storageKey) {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (blob) return void zipFiles.push({ name: uniqueName(title, fileExtension(blob.type, storageKey)), data: blob });
}
if (node.type === CanvasNodeType.Text) return void zipFiles.push({ name: uniqueName(title, "txt"), data: node.metadata?.content || node.metadata?.prompt || "" });
const content = node.metadata?.content;
if (content && content.startsWith("data:")) {
const blob = await (await fetch(content)).blob();
return void zipFiles.push({ name: uniqueName(title, fileExtension(blob.type, storageKey)), data: blob });
}
zipFiles.push({ name: uniqueName(title, "json"), data: JSON.stringify(node, null, 2) });
}),
);
const zip = await createZip(zipFiles);
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
@@ -47,5 +81,8 @@ function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
if (mimeType.includes("mpeg") || mimeType.includes("mp3")) return "mp3";
if (mimeType.includes("wav")) return "wav";
if (mimeType.includes("ogg")) return "ogg";
return storageKey.startsWith("image:") ? "png" : "bin";
}
@@ -0,0 +1,159 @@
import { defaultConfig, type AiConfig } from "@/stores/use-config-store";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { resolveMediaUrl } from "@/services/file-storage";
import { imageMetadata, referenceUrl } from "@/lib/canvas/canvas-node-factory";
import type { NodeGenerationInput } from "@/components/canvas/canvas-node-generation";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import type { CanvasImageAngleParams } from "@/components/canvas/canvas-node-angle-dialog";
import type { ReferenceImage } from "@/types/image";
import { CanvasNodeType, type CanvasAssistantSession, type CanvasConnection, type CanvasNodeData, type CanvasNodeMetadata } from "@/types/canvas";
export function imageExtension(dataUrl: string) {
return dataUrl.match(/^data:image[/]([^;]+)/)?.[1] || dataUrl.match(/image[/]([^;]+)/)?.[1] || "png";
}
export function audioExtension(mimeType?: string) {
if (mimeType?.includes("wav")) return "wav";
if (mimeType?.includes("opus")) return "opus";
if (mimeType?.includes("aac")) return "aac";
if (mimeType?.includes("flac")) return "flac";
if (mimeType?.includes("pcm")) return "pcm";
return "mp3";
}
export function generationReferenceUrls(context: { referenceImages: ReferenceImage[]; referenceVideos: Array<{ storageKey?: string; url?: string }>; referenceAudios?: Array<{ storageKey?: string; url?: string }> }) {
return [
...context.referenceImages.map(referenceUrl).filter((url): url is string => Boolean(url)),
...context.referenceVideos.map((video) => video.storageKey || video.url).filter((url): url is string => Boolean(url)),
...(context.referenceAudios || []).map((audio) => audio.storageKey || audio.url).filter((url): url is string => Boolean(url)),
];
}
export async function resolveMetadataReferences(metadata: CanvasNodeMetadata) {
if (metadata.generationType !== "edit") return [];
if (!metadata.references?.length) return null;
const references = await Promise.all(
metadata.references.map(async (url, index) => {
const dataUrl = url.startsWith("image:") ? await resolveImageUrl(url, "") : url;
return dataUrl ? { id: `${index}`, name: `reference-${index}.png`, type: "image/png", dataUrl, storageKey: url.startsWith("image:") ? url : undefined } : null;
}),
);
return references.every(Boolean) ? (references as ReferenceImage[]) : null;
}
export async function hydrateCanvasImages(nodes: CanvasNodeData[]) {
return Promise.all(
nodes.map(async (node) => {
const content = node.metadata?.content;
if ((node.type === CanvasNodeType.Video || node.type === CanvasNodeType.Audio) && node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveMediaUrl(node.metadata.storageKey, content) } };
if (node.type !== CanvasNodeType.Image || !content) return node;
if (node.metadata?.storageKey) return { ...node, metadata: { ...node.metadata, content: await resolveImageUrl(node.metadata.storageKey, content) } };
if (!content.startsWith("data:image/")) return node;
return { ...node, metadata: { ...node.metadata, ...imageMetadata(await uploadImage(content)) } };
}),
);
}
export async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
const hydrateItem = async <T extends { dataUrl?: string; storageKey?: string }>(item: T) => {
if (item.storageKey) return { ...item, dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl) };
if (item.dataUrl?.startsWith("data:image/")) {
const image = await uploadImage(item.dataUrl);
return { ...item, dataUrl: image.url, storageKey: image.storageKey };
}
return item;
};
return Promise.all(
sessions.map(async (session) => ({
...session,
messages: await Promise.all(
session.messages.map(async (message) => ({
...message,
references: await Promise.all((message.references || []).map(hydrateItem)),
})),
),
})),
);
}
export function getGenerationCount(count: string) {
return Math.max(1, Math.min(15, Math.floor(Math.abs(Number(count)) || 1)));
}
export function getInputSummary(inputs: NodeGenerationInput[]) {
return {
textCount: inputs.filter((input) => input.type === "text").length,
imageCount: inputs.filter((input) => input.type === "image").length,
videoCount: inputs.filter((input) => input.type === "video").length,
audioCount: inputs.filter((input) => input.type === "audio").length,
};
}
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
return {
...config,
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
size: node?.metadata?.size || config.size || defaultConfig.size,
background: node?.metadata?.background ?? config.background ?? defaultConfig.background,
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
};
}
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
return nodes.map((node) => (node.metadata?.status === "loading" ? { ...node, metadata: { ...node.metadata, status: "error" as const, errorDetails: "页面刷新后生成已中断,请重新生成。" } } : node));
}
export function isGenerationCanceled(error: unknown) {
return error instanceof Error && (error.message === "请求已取消" || error.name === "AbortError");
}
export function findRetrySourceNode(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
const queue = connections.filter((connection) => connection.toNodeId === nodeId).map((connection) => connection.fromNodeId);
const visited = new Set<string>();
while (queue.length) {
const id = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
const node = nodes.find((item) => item.id === id);
if (node?.type === CanvasNodeType.Config) return node;
connections.filter((connection) => connection.toNodeId === id).forEach((connection) => queue.push(connection.fromNodeId));
}
return null;
}
export function sourceNodeReferenceImages(node: CanvasNodeData | null) {
if (!node || node.type !== CanvasNodeType.Image || !node.metadata?.content) return [];
return [
{
id: node.id,
name: `${node.title || node.id}.png`,
type: node.metadata.mimeType || "image/png",
dataUrl: node.metadata.content,
storageKey: node.metadata.storageKey,
},
];
}
export function isAudioFile(file: File) {
return file.type.startsWith("audio/") || /\.(mp3|wav)$/i.test(file.name);
}
export function buildAngleLabel(params: CanvasImageAngleParams) {
const horizontal = params.horizontalAngle === 0 ? "正面视角" : params.horizontalAngle > 0 ? `向右旋转 ${params.horizontalAngle}` : `向左旋转 ${Math.abs(params.horizontalAngle)}`;
const pitch = params.pitchAngle === 0 ? "水平视角" : params.pitchAngle > 0 ? `俯视 ${params.pitchAngle}` : `仰视 ${Math.abs(params.pitchAngle)}`;
return `AI 多角度:${horizontal}${pitch},镜头距离 ${params.cameraDistance.toFixed(1)}${params.wideAngle ? "广角" : "标准"}镜头`;
}
export function buildAnglePrompt(params: CanvasImageAngleParams) {
return `基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。${buildAngleLabel(params)}`;
}
+71
View File
@@ -0,0 +1,71 @@
import { getNodeSpec, NODE_DEFAULT_SIZE } from "@/constant/canvas";
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
import type { AiConfig } from "@/stores/use-config-store";
import type { UploadedImage } from "@/services/image-storage";
import type { UploadedFile } from "@/services/file-storage";
import type { ReferenceImage } from "@/types/image";
import { CanvasNodeType, type CanvasImageGenerationType, type CanvasNodeData, type CanvasNodeMetadata, type CanvasNodeTypeId, type Position } from "@/types/canvas";
export function createCanvasNode(type: CanvasNodeTypeId, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
const spec = getNodeSpec(type);
const id = `${type}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return {
id,
type,
title: spec.title,
position: {
x: position.x - spec.width / 2,
y: position.y - spec.height / 2,
},
width: spec.width,
height: spec.height,
metadata: { ...spec.metadata, ...metadata },
};
}
export function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
return { content: image.url, storageKey: image.storageKey, status: "success", naturalWidth: image.width, naturalHeight: image.height, bytes: image.bytes, mimeType: image.mimeType };
}
export function videoMetadata(video: UploadedFile): CanvasNodeMetadata {
return { content: video.url, storageKey: video.storageKey, status: "success", naturalWidth: video.width, naturalHeight: video.height, bytes: video.bytes, mimeType: video.mimeType || "video/mp4", durationMs: video.durationMs };
}
export function audioMetadata(audio: UploadedFile): CanvasNodeMetadata {
return { content: audio.url, storageKey: audio.storageKey, status: "success", bytes: audio.bytes, mimeType: audio.mimeType || "audio/mpeg", durationMs: audio.durationMs };
}
export function referenceUrl(image: ReferenceImage) {
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
}
export function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: AiConfig, count: number, references: ReferenceImage[]): CanvasNodeMetadata {
return {
generationType: type,
model: config.model,
size: config.size,
quality: config.quality,
...(config.background ? { background: config.background } : {}),
count,
references: references.map(referenceUrl).filter((url): url is string => Boolean(url)),
};
}
export function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
return {
model: config.model,
audioVoice: config.audioVoice,
audioFormat: config.audioFormat,
audioSpeed: config.audioSpeed,
audioInstructions: config.audioInstructions,
};
}
export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeData["metadata"]>) {
const safePatch = patch || {};
const next = { ...node, metadata: { ...node.metadata, ...safePatch } };
const spec = node.type === CanvasNodeType.Video ? NODE_DEFAULT_SIZE[CanvasNodeType.Video] : NODE_DEFAULT_SIZE[CanvasNodeType.Image];
const size = typeof safePatch.size === "string" && !node.metadata?.content ? nodeSizeFromRatio(safePatch.size, spec.width, spec.height) : null;
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
}
@@ -0,0 +1,91 @@
import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle } from "@/types/canvas";
export function nodeBounds(nodes: CanvasNodeData[]) {
return nodes.reduce(
(acc, node) => ({
left: Math.min(acc.left, node.position.x),
top: Math.min(acc.top, node.position.y),
right: Math.max(acc.right, node.position.x + node.width),
bottom: Math.max(acc.bottom, node.position.y + node.height),
}),
{ left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity },
);
}
export function findGroupDropTarget(movedIds: Set<string>, nodes: CanvasNodeData[]) {
if (nodes.some((node) => movedIds.has(node.id) && node.type === CanvasNodeType.Group)) return null;
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
if (!movingNodes.length) return null;
return (
[...nodes].reverse().find((group) => {
if (group.type !== CanvasNodeType.Group || movedIds.has(group.id)) return false;
return movingNodes.some((node) => {
const centerX = node.position.x + node.width / 2;
const centerY = node.position.y + node.height / 2;
return centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height;
});
}) || null
);
}
export function snapNodesIntoGroup(movedIds: Set<string>, nodes: CanvasNodeData[], group: CanvasNodeData) {
const movingNodes = nodes.filter((node) => movedIds.has(node.id) && node.type !== CanvasNodeType.Group);
if (!movingNodes.length) return nodes;
const pad = 24;
const bounds = nodeBounds(movingNodes);
const left = group.position.x + pad;
const top = group.position.y + pad;
const right = group.position.x + group.width - pad;
const bottom = group.position.y + group.height - pad;
const dx = bounds.right - bounds.left > right - left ? left - bounds.left : bounds.left < left ? left - bounds.left : bounds.right > right ? right - bounds.right : 0;
const dy = bounds.bottom - bounds.top > bottom - top ? top - bounds.top : bounds.top < top ? top - bounds.top : bounds.bottom > bottom ? bottom - bounds.bottom : 0;
return nodes.map((node) => {
if (!movedIds.has(node.id) || node.type === CanvasNodeType.Group) return node;
return { ...node, position: { x: node.position.x + dx, y: node.position.y + dy }, metadata: { ...node.metadata, groupId: group.id } };
});
}
export function findContainingGroupId(node: CanvasNodeData, nodes: CanvasNodeData[]) {
const centerX = node.position.x + node.width / 2;
const centerY = node.position.y + node.height / 2;
return (
[...nodes]
.reverse()
.find((group) => group.type === CanvasNodeType.Group && group.id !== node.id && centerX >= group.position.x && centerX <= group.position.x + group.width && centerY >= group.position.y && centerY <= group.position.y + group.height)?.id ||
undefined
);
}
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
return {
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
y: node.position.y + node.height / 2,
};
}
export function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
const first = nodes.find((node) => node.id === firstNodeId);
const second = nodes.find((node) => node.id === secondNodeId);
if (!first || !second || first.id === second.id) return null;
if (first.type === CanvasNodeType.Group || second.type === CanvasNodeType.Group) return null;
if (first.type === CanvasNodeType.Config && second.type === CanvasNodeType.Config) return null;
if (second.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
if (first.type === CanvasNodeType.Config && firstHandleType === "target") return { fromNodeId: second.id, toNodeId: first.id };
if (first.type === CanvasNodeType.Config) return { fromNodeId: first.id, toNodeId: second.id };
return { fromNodeId: first.id, toNodeId: second.id };
}
export function isHiddenBatchChild(node: CanvasNodeData, nodes: CanvasNodeData[], collapsingBatchIds?: Set<string>) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
const root = nodes.find((item) => item.id === rootId);
if (root && collapsingBatchIds?.has(rootId)) return false;
return Boolean(root && !root.metadata?.imageBatchExpanded);
}
export function isHiddenBatchConnectionEndpoint(node: CanvasNodeData, nodes: CanvasNodeData[]) {
const rootId = node.metadata?.batchRootId;
if (!rootId) return false;
const root = nodes.find((item) => item.id === rootId);
return Boolean(root && !root.metadata?.imageBatchExpanded);
}
@@ -16,13 +16,6 @@ export type CanvasResourceReference = {
active: boolean;
};
export function buildCanvasResourceReferences(nodes: CanvasNodeData[], connections: CanvasConnection[], contextNodeId?: string | null) {
const contextNodes = contextNodeId ? getMentionResourceNodes(contextNodeId, nodes, connections) : [];
const globalReferences = labelResourceNodes(nodes.filter(isResourceNode), false);
const activeByNodeId = new Map(labelResourceNodes(contextNodes, true).map((reference) => [reference.nodeId, reference]));
return globalReferences.map((reference) => activeByNodeId.get(reference.nodeId) || reference);
}
export function buildNodeMentionReferences(node: CanvasNodeData, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
return labelResourceNodes(getMentionResourceNodes(node.id, nodes, connections), true);
}
+20 -7
View File
@@ -55,9 +55,11 @@ function withCacheBust(url: string) {
return `${url}${url.includes("?") ? "&" : "?"}t=${Date.now()}`;
}
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
export async function installPluginFromUrl(url: string, opts?: { official?: boolean }) {
const source = await fetchPluginSource(url);
// 从 URL 安装(或覆盖更新)一个插件,成功后立即启用
// bustCache=true 时下载绕过 HTTP/CDN 缓存(升级场景必需,避免拿到旧产物),
// 但落库的 url 始终保持干净(不带 ?t=),便于后续再次更新。
export async function installPluginFromUrl(url: string, opts?: { official?: boolean; bustCache?: boolean }) {
const source = await fetchPluginSource(opts?.bustCache ? withCacheBust(url) : url);
const plugin = await evaluatePluginSource(source);
deactivatePlugin(plugin.id); // 覆盖旧版本
usePluginStore.getState().upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: true, official: opts?.official });
@@ -66,7 +68,8 @@ export async function installPluginFromUrl(url: string, opts?: { official?: bool
}
export async function updatePlugin(record: InstalledPlugin) {
return installPluginFromUrl(record.url, { official: record.official });
// 升级必须拿到最新产物,强制绕过缓存
return installPluginFromUrl(record.url, { official: record.official, bustCache: true });
}
export async function setPluginEnabled(record: InstalledPlugin, enabled: boolean) {
@@ -111,7 +114,8 @@ export async function ensurePluginsLoaded() {
// 自动发现 web/public/plugins 下的本地插件:加入列表但默认关闭,
// 本地开发放好插件文件即可在管理器里看到并一键启用,无需手动填 URL。
// 已在列表中的(用户装过/发现过)不覆盖,尊重其现有开关。
// 已在列表中的:刷新元数据(version/name/description/source)到最新产物,
// 但保留用户的 enabled 开关 —— 否则改了插件版本后,持久化 store 里的旧 version 永不更新。
async function loadLocalPlugins() {
let urls: unknown;
try {
@@ -128,8 +132,17 @@ async function loadLocalPlugins() {
try {
const source = await fetchPluginSource(withCacheBust(url));
const plugin = await evaluatePluginSource(source);
if (store.plugins.some((item) => item.id === plugin.id)) return;
store.upsert({ id: plugin.id, name: plugin.name || plugin.id, version: plugin.version || "0.0.0", description: plugin.description, url, source, enabled: false, local: true });
const existing = store.plugins.find((item) => item.id === plugin.id);
store.upsert({
id: plugin.id,
name: plugin.name || plugin.id,
version: plugin.version || "0.0.0",
description: plugin.description,
url,
source,
enabled: existing?.enabled ?? false, // 保留用户开关,新发现默认关闭
local: true,
});
} catch (error) {
console.error(`[plugin] 本地插件发现失败: ${url}`, error);
}
+5 -1
View File
@@ -5,12 +5,13 @@ import type { CanvasNodeData } from "@/types/canvas";
import type { CanvasNodeContext, CanvasPluginHost } from "@/types/canvas-plugin";
// 把宿主能力 + 节点 + 主题/缩放,组装成注入给插件节点的上下文
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number): CanvasNodeContext {
export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, theme: CanvasTheme, scale: number, isSelected = false): CanvasNodeContext {
const storage = createPluginStorage(getNodePluginId(node.type));
return {
node,
theme,
scale,
isSelected,
updateMetadata: (patch) => host.updateMetadata(node.id, patch),
updateNode: (patch) => host.updateNode(node.id, patch),
getNode: (id) => host.getNode(id),
@@ -21,6 +22,9 @@ export function buildNodeContext(host: CanvasPluginHost, node: CanvasNodeData, t
applyOps: (ops) => host.applyOps(ops),
emit: (event, payload) => emitCanvasEvent(event, payload),
on: (event, handler) => onCanvasEvent(event, handler),
ai: host.ai,
openPanel: () => host.openPanel(node.id),
closePanel: () => host.closePanel(),
storage,
};
}
+17
View File
@@ -30,3 +30,20 @@ export async function fetchOfficialPlugins(registryUrl: string = PLUGIN_REGISTRY
url: item.url ? item.url : new URL(item.entry as string, registryUrl).toString(),
}));
}
// 语义化版本比较:返回 >0 表示 a 更高,<0 表示 b 更高,0 表示相等。
// 只按 major.minor.patch 数值比较,忽略非数字段(预发布标签等)。
function compareSemver(a: string, b: string): number {
const parse = (v: string) => v.split(".").map((part) => parseInt(part, 10) || 0);
const [pa, pb] = [parse(a), parse(b)];
for (let i = 0; i < 3; i++) {
const diff = (pa[i] || 0) - (pb[i] || 0);
if (diff !== 0) return diff;
}
return 0;
}
// 远程版本是否比本地已安装版本更高(即有可升级的更新)
export function hasUpgrade(installedVersion: string, remoteVersion: string): boolean {
return compareSemver(remoteVersion, installedVersion) > 0;
}
+3
View File
@@ -6,8 +6,11 @@ import "./styles/globals.css";
import { RouterProvider } from "react-router-dom";
import { AppProviders } from "@/components/layout/app-providers";
import { initAnalytics } from "@/lib/analytics";
import { router } from "@/router";
initAnalytics();
document.body.style.fontFamily = '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif';
createRoot(document.getElementById("root")!).render(
@@ -0,0 +1,97 @@
import { useCallback, useEffect, useMemo, useState, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { useAgentStore } from "@/stores/use-agent-store";
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasNodeGenerationMode } from "@/components/canvas/canvas-node-prompt-panel";
import type { CanvasConnection, CanvasNodeData, ContextMenuState, ViewportTransform } from "@/types/canvas";
type GenerateNodeRef = MutableRefObject<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise<void>) | null>;
type AgentBridgeParams = {
projectId: string;
title: string | undefined;
nodes: CanvasNodeData[];
connections: CanvasConnection[];
selectedNodeIds: Set<string>;
viewport: ViewportTransform;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
selectedNodeIdsRef: MutableRefObject<Set<string>>;
viewportRef: MutableRefObject<ViewportTransform>;
generateNodeRef: GenerateNodeRef;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setConnections: Dispatch<SetStateAction<CanvasConnection[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
setViewport: Dispatch<SetStateAction<ViewportTransform>>;
setContextMenu: Dispatch<SetStateAction<ContextMenuState | null>>;
};
/**
* 画布与本地 Agent 的桥接:把当前画布快照与 apply/undo 能力发布到 agent store
* 供本地 Codex 面板读取。除 applyAgentOps(配置节点插件宿主会用到)外均为内部实现。
*/
export function useAgentBridge(params: AgentBridgeParams) {
const { projectId, title, nodes, connections, selectedNodeIds, viewport, nodesRef, connectionsRef, selectedNodeIdsRef, viewportRef, generateNodeRef, setNodes, setConnections, setSelectedNodeIds, setSelectedConnectionId, setViewport, setContextMenu } =
params;
const setAgentCanvasContext = useAgentStore((state) => state.setCanvasContext);
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
const projectTitle = title || "未命名画布";
const agentSnapshot = useMemo<CanvasAgentSnapshot>(() => ({ projectId, title: projectTitle, nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }), [connections, projectTitle, nodes, projectId, selectedNodeIds, viewport]);
const applyAgentOps = useCallback(
(ops?: CanvasAgentOp[]) => {
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
const before = { projectId, title: projectTitle, nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
const next = applyCanvasAgentOps(
before,
safeOps.filter((op) => op.type !== "run_generation"),
);
nodesRef.current = next.nodes;
connectionsRef.current = next.connections;
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
viewportRef.current = next.viewport;
setAgentUndoSnapshot(before);
setNodes(next.nodes);
setConnections(next.connections);
setSelectedNodeIds(new Set(next.selectedNodeIds));
setSelectedConnectionId(null);
setViewport(next.viewport);
setContextMenu(null);
if (generationOps.length) {
queueMicrotask(() =>
generationOps.forEach((op) => {
const target = nodesRef.current.find((node) => node.id === op.nodeId);
const prompt = op.prompt?.trim() ? op.prompt : (target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt);
}),
);
}
return { ...next, projectId, title: projectTitle };
},
[projectTitle, projectId],
);
const undoAgentOps = useCallback(() => {
if (!agentUndoSnapshot) return null;
nodesRef.current = agentUndoSnapshot.nodes;
connectionsRef.current = agentUndoSnapshot.connections;
selectedNodeIdsRef.current = new Set(agentUndoSnapshot.selectedNodeIds);
viewportRef.current = agentUndoSnapshot.viewport;
setNodes(agentUndoSnapshot.nodes);
setConnections(agentUndoSnapshot.connections);
setSelectedNodeIds(new Set(agentUndoSnapshot.selectedNodeIds));
setSelectedConnectionId(null);
setViewport(agentUndoSnapshot.viewport);
setContextMenu(null);
setAgentUndoSnapshot(null);
return { ...agentUndoSnapshot, projectId, title: projectTitle };
}, [agentUndoSnapshot, projectTitle, projectId]);
useEffect(() => {
setAgentCanvasContext({ snapshot: agentSnapshot, applyOps: applyAgentOps, undoOps: undoAgentOps, canUndo: Boolean(agentUndoSnapshot) });
return () => setAgentCanvasContext(null);
}, [agentSnapshot, applyAgentOps, agentUndoSnapshot, setAgentCanvasContext, undoAgentOps]);
return { applyAgentOps };
}
@@ -0,0 +1,144 @@
import { useCallback, useEffect, useMemo, type Dispatch, type MutableRefObject, type SetStateAction } from "react";
import { requestEdit, requestGeneration, requestImageQuestion, type AiTextMessage } from "@/services/api/image";
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
import { decodeChannelModel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
import { buildGenerationConfig } from "@/lib/canvas/canvas-generation-helpers";
import { buildNodeContext } from "@/lib/canvas/plugin-node-context";
import { getNodeDefinition } from "@/lib/canvas/node-registry";
import { ensurePluginsLoaded } from "@/lib/canvas/plugin-loader";
import { canvasThemes } from "@/lib/canvas-theme";
import type { CanvasNodeToolbarItem, CanvasPluginAi, CanvasPluginHost } from "@/types/canvas-plugin";
import type { ReferenceImage } from "@/types/image";
import type { CanvasAgentOp } from "@/lib/canvas/canvas-agent-ops";
import type { CanvasConnection, CanvasNodeData, ViewportTransform } from "@/types/canvas";
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
type PluginHostParams = {
effectiveConfig: AiConfig;
isAiConfigReady: (config: AiConfig, model: string) => boolean;
openConfigDialog: (open: boolean) => void;
theme: CanvasTheme;
nodesRef: MutableRefObject<CanvasNodeData[]>;
connectionsRef: MutableRefObject<CanvasConnection[]>;
viewportRef: MutableRefObject<ViewportTransform>;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setDialogNodeId: Dispatch<SetStateAction<string | null>>;
applyAgentOps: (ops?: CanvasAgentOp[]) => unknown;
};
/**
* 插件节点宿主能力:把宿主侧的 AI 生成、画布读写、面板开关等封装成插件可调用的 host/ai 对象,
* 并在挂载时加载已安装的远程插件。返回给画布用于渲染插件面板与工具条。
*/
export function usePluginHost(params: PluginHostParams) {
const { effectiveConfig, isAiConfigReady, openConfigDialog, theme, nodesRef, connectionsRef, viewportRef, setNodes, setDialogNodeId, applyAgentOps } = params;
// 提供给插件节点的宿主能力(节点无关,方法接收 nodeId)
const pluginAi = useMemo<CanvasPluginAi>(() => {
// 把插件传入的参考图(dataURL 或 URL)整理成宿主生成 API 需要的 ReferenceImage[]
const toReferences = (refs?: string[]): ReferenceImage[] => (refs || []).filter(Boolean).map((src, index) => ({ id: `plugin-ref-${index}`, name: `ref-${index}.png`, type: "image/png", dataUrl: src }));
// AI 配置未就绪:弹出配置弹窗并抛错,交由插件 catch 处理
const ensureReady = (config: AiConfig) => {
if (!isAiConfigReady(config, config.model)) {
openConfigDialog(true);
throw new Error("AI 配置未就绪,请先在设置里配置模型与密钥");
}
};
return {
generateImage: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "image"), count: String(options?.count || 1), ...(options?.model ? { model: options.model } : {}), ...(options?.size ? { size: options.size } : {}) };
ensureReady(config);
const references = toReferences(options?.references);
const items = references.length ? await requestEdit(config, prompt, references, undefined, { signal: options?.signal }) : await requestGeneration(config, prompt, { signal: options?.signal });
return { images: items.map((item) => item.dataUrl) };
},
generateVideo: async (prompt, options) => {
const config = {
...buildGenerationConfig(effectiveConfig, undefined, "video"),
...(options?.model ? { model: options.model } : {}),
...(options?.size ? { size: options.size } : {}),
...(options?.seconds ? { videoSeconds: options.seconds } : {}),
};
ensureReady(config);
const file = await storeGeneratedVideo(await requestVideoGeneration(config, prompt, toReferences(options?.references), [], [], { signal: options?.signal }));
return { url: file.url, mimeType: file.mimeType, width: file.width, height: file.height, durationMs: file.durationMs };
},
generateText: async (prompt, options) => {
const config = { ...buildGenerationConfig(effectiveConfig, undefined, "text"), ...(options?.model ? { model: options.model } : {}) };
ensureReady(config);
const messages: AiTextMessage[] = [...(options?.system ? [{ role: "system" as const, content: options.system }] : []), { role: "user" as const, content: prompt }];
const text = await requestImageQuestion(config, messages, (delta) => options?.onDelta?.(delta), { signal: options?.signal });
return { text };
},
// 列出某能力下用户已配置的模型;label 取编码值中的模型名(去掉 channel 前缀)
listModels: (capability) => selectableModelsByCapability(effectiveConfig, capability as ModelCapability | undefined).map((value) => ({ value, label: decodeChannelModel(value)?.model || value })),
defaultModel: (capability) => buildGenerationConfig(effectiveConfig, undefined, capability).model,
};
}, [effectiveConfig, isAiConfigReady, openConfigDialog]);
const pluginHost = useMemo<CanvasPluginHost>(
() => ({
getNode: (id) => nodesRef.current.find((node) => node.id === id) || null,
getNodes: () => nodesRef.current,
getConnections: () => connectionsRef.current,
getUpstream: (nodeId) =>
connectionsRef.current
.filter((conn) => conn.toNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.fromNodeId))
.filter((node): node is CanvasNodeData => Boolean(node)),
getDownstream: (nodeId) =>
connectionsRef.current
.filter((conn) => conn.fromNodeId === nodeId)
.map((conn) => nodesRef.current.find((node) => node.id === conn.toNodeId))
.filter((node): node is CanvasNodeData => Boolean(node)),
updateNode: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, ...patch } : node))),
updateMetadata: (nodeId, patch) => setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, ...patch } } : node))),
applyOps: (ops) => applyAgentOps(ops),
ai: pluginAi,
openPanel: (nodeId) => setDialogNodeId(nodeId),
closePanel: () => setDialogNodeId(null),
}),
[applyAgentOps, pluginAi],
);
const renderPluginPanel = useCallback(
(panelNode: CanvasNodeData) => {
const Panel = getNodeDefinition(panelNode.type)?.Panel;
if (!Panel) return null;
const ctx = buildNodeContext(pluginHost, panelNode, theme, viewportRef.current.k);
return <Panel ctx={ctx} onClose={() => setDialogNodeId(null)} />;
},
[pluginHost, theme],
);
// 组装节点悬浮工具条按钮:插件自定义 toolbar +(声明 interactionToggle 时)宿主自动注入的「交互 ⇄ 移动」开关
const buildNodeToolbarItems = useCallback(
(node: CanvasNodeData): CanvasNodeToolbarItem[] => {
const definition = getNodeDefinition(node.type);
const ctx = buildNodeContext(pluginHost, node, theme, viewportRef.current.k);
const custom = definition?.toolbar?.(ctx) || [];
// 仅在节点有内容(展示态)且非强制交互态(如编辑态)时提供「交互/移动」开关
if (!definition?.interactionToggle || !node.metadata?.content || definition.forceInteractive?.(node)) return custom;
const interactive = Boolean(node.metadata?.interactive);
const toggle: CanvasNodeToolbarItem = {
id: "node-interaction-toggle",
title: interactive ? "当前:交互中。点击切回「移动」——拖动可移动节点" : "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)",
label: interactive ? "移动" : "交互",
icon: interactive ? "✋" : "🖐",
active: interactive,
onClick: () => pluginHost.updateMetadata(node.id, { interactive: !interactive }),
};
return [toggle, ...custom];
},
[pluginHost, theme],
);
// 启动时加载已安装的远程插件
useEffect(() => {
void ensurePluginsLoaded();
}, []);
return { pluginHost, renderPluginPanel, buildNodeToolbarItems };
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -1,5 +1,6 @@
import { createBrowserRouter, Outlet } from "react-router-dom";
import { AnalyticsTracker } from "@/components/layout/analytics-tracker";
import UserLayout from "@/layouts/user-layout";
import AssetsPage from "@/pages/assets";
import CanvasPage from "@/pages/canvas";
@@ -15,6 +16,7 @@ export const router = createBrowserRouter([
{
element: (
<UserLayout>
<AnalyticsTracker />
<Outlet />
</UserLayout>
),
+15 -2
View File
@@ -122,6 +122,11 @@ function normalizeQuality(quality: string) {
return QUALITY_BASE[normalized] ? normalized : undefined;
}
/** Only "transparent" is forwarded; any other value (incl. empty) means keep the default opaque background. */
function normalizeBackground(background: string | undefined) {
return background?.trim().toLowerCase() === "transparent" ? "transparent" : undefined;
}
/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". */
function resolveSize(quality: string | undefined, ratio: string): string {
const parsedRatio = parseImageRatio(ratio);
@@ -661,6 +666,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
if (script) {
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
try {
const result = await runModelPlugin({
capability: "image",
@@ -668,7 +674,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
config: requestConfig,
prompt: withSystemPrompt(requestConfig, prompt),
images: [],
params: { size: requestSize, quality, count: n },
params: { size: requestSize, quality, count: n, ...(background ? { background } : {}) },
signal: options?.signal,
});
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
@@ -685,6 +691,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
}
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
try {
const response = await axios.post<ImageApiResponse>(
aiApiUrl(requestConfig, "/images/generations"),
@@ -694,6 +701,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
n,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
...(background ? { background } : {}),
response_format: "b64_json",
output_format: IMAGE_OUTPUT_FORMAT,
},
@@ -717,6 +725,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
if (script) {
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
try {
const result = await runModelPlugin({
@@ -725,7 +734,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
config: requestConfig,
prompt: withSystemPrompt(requestConfig, requestPrompt),
images: refs,
params: { size: requestSize, quality, count: n },
params: { size: requestSize, quality, count: n, ...(background ? { background } : {}) },
signal: options?.signal,
});
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
@@ -743,6 +752,7 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
}
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const background = normalizeBackground(config.background);
const formData = new FormData();
formData.set("model", requestConfig.model);
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
@@ -755,6 +765,9 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
if (requestSize) {
formData.set("size", requestSize);
}
if (background) {
formData.set("background", background);
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file));
if (mask) formData.set("mask", dataUrlToFile(mask));
@@ -0,0 +1,53 @@
import { create } from "zustand";
export const CANVAS_SIDE_PANEL_MOTION_MS = 500;
export const CANVAS_SIDE_PANEL_MIN_WIDTH = 220;
export const CANVAS_SIDE_PANEL_MAX_WIDTH = 480;
export const CANVAS_SIDE_PANEL_DEFAULT_WIDTH = 280;
const WIDTH_KEY = "canvas-side-panel-width";
const OPEN_KEY = "canvas-side-panel-open";
function initialWidth() {
if (typeof window === "undefined") return CANVAS_SIDE_PANEL_DEFAULT_WIDTH;
const stored = Number(localStorage.getItem(WIDTH_KEY));
if (!stored) return CANVAS_SIDE_PANEL_DEFAULT_WIDTH;
return Math.min(CANVAS_SIDE_PANEL_MAX_WIDTH, Math.max(CANVAS_SIDE_PANEL_MIN_WIDTH, stored));
}
function initialOpen() {
if (typeof window === "undefined") return true;
return localStorage.getItem(OPEN_KEY) !== "0";
}
type CanvasSidePanelStore = {
width: number;
panelOpen: boolean;
panelMounted: boolean;
panelClosing: boolean;
setWidth: (width: number) => void;
openPanel: () => void;
closePanel: () => void;
togglePanel: () => void;
};
export const useCanvasSidePanelStore = create<CanvasSidePanelStore>((set, get) => ({
width: initialWidth(),
panelOpen: initialOpen(),
panelMounted: initialOpen(),
panelClosing: false,
setWidth: (width) => set({ width }),
openPanel: () => {
if (typeof window !== "undefined") localStorage.setItem(OPEN_KEY, "1");
set({ panelOpen: true, panelMounted: true, panelClosing: false });
},
closePanel: () => {
if (!get().panelMounted || get().panelClosing) return;
if (typeof window !== "undefined") localStorage.setItem(OPEN_KEY, "0");
set({ panelOpen: false, panelClosing: true });
setTimeout(() => {
if (get().panelClosing) set({ panelMounted: false, panelClosing: false });
}, CANVAS_SIDE_PANEL_MOTION_MS);
},
togglePanel: () => (get().panelOpen ? get().closePanel() : get().openPanel()),
}));
+2
View File
@@ -44,6 +44,7 @@ export type AiConfig = {
models: string[];
quality: string;
size: string;
background: string;
count: string;
canvasImageCount: string;
};
@@ -99,6 +100,7 @@ export const defaultConfig: AiConfig = {
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
quality: "auto",
size: "1:1",
background: "",
count: "1",
canvasImageCount: "3",
};
+45
View File
@@ -8,6 +8,25 @@ import type { CanvasResourceKind } from "@/lib/canvas/canvas-resource-references
// 插件节点作为上游输入被消费时输出的资源
export type CanvasNodeResource = { kind: CanvasResourceKind; text?: string; url?: string };
// --- AI 生成能力(生图/生视频/生文本),由宿主注入,复用宿主模型/密钥配置 ---
export type GenerateOptions = { signal?: AbortSignal; references?: string[]; model?: string };
export type GenerateImageOptions = GenerateOptions & { count?: number; size?: string };
export type GenerateImageResult = { images: string[] };
export type GenerateVideoOptions = GenerateOptions & { size?: string; seconds?: string };
export type GenerateVideoResult = { url: string; 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 };
export type PluginModelCapability = "image" | "video" | "text" | "audio";
export type ModelOption = { value: string; label: string };
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?: PluginModelCapability) => ModelOption[];
defaultModel: (capability: PluginModelCapability) => string;
};
// 节点自带的工具栏按钮(追加到 hover 工具栏尾部)
export type CanvasNodeToolbarItem = {
id: string;
@@ -24,6 +43,7 @@ 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;
@@ -38,6 +58,11 @@ export type CanvasNodeContext = {
// 节点间/插件间通信
emit: (event: string, payload?: unknown) => void;
on: (event: string, handler: (payload: unknown) => void) => () => void;
// AI 生成能力(生图/生视频/生文本),复用宿主模型配置
ai: CanvasPluginAi;
// 打开/关闭本节点下方的自定义 Panel(需在节点定义里提供 Panel)
openPanel: () => void;
closePanel: () => void;
// 插件私有持久化,命名空间隔离
storage: PluginStorage;
};
@@ -58,6 +83,18 @@ export type CanvasPluginHost = {
updateNode: (nodeId: string, patch: Partial<Pick<CanvasNodeData, "title" | "width" | "height">>) => void;
updateMetadata: (nodeId: string, patch: CanvasNodeMetadata) => void;
applyOps: (ops: CanvasAgentOp[]) => void;
// AI 生成能力,复用画布页面当前的模型/密钥配置
ai: CanvasPluginAi;
// 打开/关闭指定节点下方的自定义 Panel
openPanel: (nodeId: string) => void;
closePanel: () => void;
};
// 复用宿主内置生成面板的配置(见 SDK CanvasBuiltinPanelConfig)
export type CanvasBuiltinPanelConfig = {
mode: "image" | "video" | "text" | "audio";
promptPrefix?: string;
writeBackToSelf?: boolean;
};
// 节点类型定义:内置节点与插件节点统一走这套结构
@@ -71,6 +108,14 @@ export type CanvasNodeDefinition = {
minimapColor?: string;
showInCreateMenu?: boolean; // 默认 true
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
hidePanel?: boolean; // 为 true 时:点击/新建不弹出下方面板(含内置生图面板),纯展示型节点用
transparentBackground?: boolean; // 为 true 时:节点卡片背景与边框透明,内容直接融入画布(如 SVG/矢量图)
autoOpenPanel?: boolean; // 为 true 时:单击节点自动打开自定义 Panel(默认仅内置节点单击自动打开)
useBuiltinPanel?: CanvasBuiltinPanelConfig; // 复用宿主内置生成面板(与自定义 Panel 二选一)
// 为 true 时:宿主自动提供「交互 ⇄ 移动」工具条开关,并按 metadata.interactive 控制内容层指针事件
interactionToggle?: boolean;
// 配合 interactionToggle:返回 true 表示内容强制可交互(如编辑态),忽略 interactive 并隐藏开关
forceInteractive?: (node: CanvasNodeData) => boolean;
keepAspectRatio?: (node: CanvasNodeData) => boolean;
resource?: (node: CanvasNodeData) => CanvasNodeResource | null;
// 渲染:内置节点由 canvas-node 内部渲染器负责,可不提供 Content
+2
View File
@@ -37,6 +37,7 @@ export type CanvasNodeMetadata = {
model?: string;
size?: string;
quality?: string;
background?: string;
count?: number;
seconds?: string;
vquality?: string;
@@ -61,6 +62,7 @@ export type CanvasNodeMetadata = {
bytes?: number;
durationMs?: number;
groupId?: string;
interactive?: boolean; // 插件节点「交互 ⇄ 移动」开关状态(见 CanvasNodeDefinition.interactionToggle)
};
export type CanvasNodeData = {
+5
View File
@@ -6,4 +6,9 @@ declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[];
interface ImportMetaEnv {
// 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库)
readonly VITE_DEV_PLUGINS?: string;
// 统计分析(可选,构建期注入):每家一个独立变量,填了谁就启用谁,可同时启用多家
// GA4 衡量 IDG-XXXX
readonly VITE_ANALYTICS_GA4_ID?: string;
// 百度统计站点 ID
readonly VITE_ANALYTICS_BAIDU_ID?: string;
}