mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-26 08:24:29 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e1edd1484 | |||
| 5107be0acf | |||
| 219b3e84db | |||
| a3411cf9d9 | |||
| 430d1bd956 | |||
| cf0561d12a | |||
| e6dcd7a832 | |||
| 187c499ef9 | |||
| cf1ea2646f | |||
| 57db0fe752 | |||
| c5f53454aa | |||
| ca6efdf0c3 | |||
| a2586ffe34 | |||
| 5a27aae99e | |||
| 66961ce10f |
@@ -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` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
|
||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||
|
||||
|
||||
@@ -2,6 +2,19 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.9.0 - 2026-07-17
|
||||
|
||||
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
|
||||
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
|
||||
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
|
||||
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
|
||||
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
|
||||
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
|
||||
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
|
||||
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
|
||||
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
|
||||
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
|
||||
|
||||
## v0.8.2 - 2026-07-16
|
||||
|
||||
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||
|
||||
@@ -14,5 +14,7 @@ FROM nginx:1.27-alpine
|
||||
|
||||
COPY --from=web-build /app/web/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -32,10 +32,12 @@
|
||||
## 核心功能
|
||||
|
||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
||||
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
||||
- 插件系统:支持通过 URL 动态安装 / 启用 / 更新 / 卸载远程节点插件,并提供 TypeScript SDK 自行开发画布节点插件。
|
||||
- 自定义接口调用:可自定义生图 / 视频接口的调用方式,灵活适配各类中转站与自建服务。
|
||||
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
|
||||
|
||||
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
|
||||
@@ -68,16 +70,7 @@ docker compose up -d
|
||||
|
||||
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
||||
|
||||
## New API 自动配置
|
||||
|
||||
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
|
||||
|
||||
```text
|
||||
https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
```
|
||||
|
||||
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
|
||||
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
|
||||
如果默认的OpenAI接口调用方式与您的API不同,可自定义生图/视频脚本调用。
|
||||
|
||||
## 效果展示
|
||||
|
||||
@@ -108,20 +101,9 @@ https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
|
||||
## 赞助支持
|
||||
|
||||
<div align="center">
|
||||
本项目长期开放广告赞助合作,欢迎品牌 / 产品投放,你的支持是持续更新的动力!
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力!
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://ifdian.net/a/basketikun">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
有广告赞助意向请通过上方联系方式沟通。
|
||||
|
||||
## 社区支持
|
||||
|
||||
|
||||
@@ -5,3 +5,7 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
|
||||
# environment:
|
||||
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
|
||||
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
|
||||
|
||||
@@ -24,6 +24,7 @@ description: 当前画布节点的主要用途与操作流程
|
||||
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
||||
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
||||
- 输入内容可以手写,也可以从提示词库选择。
|
||||
- 输入框支持 `@` 引用已连接的图片、文本、视频、音频资源;`@` 引用图片时会在输入框内直接显示真实缩略图,发送时按当前连接自动编号交给模型。因此不需要再记忆「图片1 / 文本1」编号,画布节点右上角也不再显示资源角标。
|
||||
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
||||
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"canvas-data-structure"
|
||||
"canvas-data-structure",
|
||||
"local-codex-canvas"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -50,3 +50,42 @@ docker compose -f docker-compose.local.yml up -d --build
|
||||
## 数据说明
|
||||
|
||||
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||
|
||||
## 统计分析(可选)
|
||||
|
||||
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
|
||||
|
||||
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID(`G-XXXX`) |
|
||||
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID(`hm.js?` 后面那串 hash) |
|
||||
|
||||
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
ANALYTICS_GA4_ID: G-XXXXXXXXXX
|
||||
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
|
||||
```
|
||||
|
||||
或使用 `docker run`:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
|
||||
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
|
||||
ghcr.io/basketikun/infinite-canvas:latest
|
||||
```
|
||||
|
||||
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
|
||||
|
||||
<Callout type="warn">
|
||||
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
|
||||
</Callout>
|
||||
|
||||
@@ -85,9 +85,12 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 生成数量。
|
||||
- 视频模型。
|
||||
- 视频比例、清晰度、时长、生成声音和水印。
|
||||
- 自定义生图 / 视频接口的调用方式,灵活适配不同中转站与自建服务。
|
||||
|
||||
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
||||
|
||||
节点下方对话框和组装提示词输入框都支持 `@` 引用已连接的图片、文本、视频、音频等资源;`@` 引用图片时,输入框内会直接显示该图片的真实缩略图,而不再是「图片1」这类文字编号,发送时会按当前连接自动编号交给模型理解。由于引用改在对话框内直接 `@`,画布节点右上角不再显示「图片1 / 文本1」资源角标。
|
||||
|
||||
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
||||
|
||||
## 画布助手
|
||||
@@ -106,6 +109,18 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 把助手生成的图片插入画布。
|
||||
- 折叠和展开助手面板。
|
||||
|
||||
## 节点插件系统
|
||||
|
||||
画布支持通过节点插件扩展新的节点类型,内置节点之外的能力都可以以插件形式加载:
|
||||
|
||||
- 支持通过 URL 动态安装、启用、更新和卸载远程节点插件。
|
||||
- 提供官方插件注册表,节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
- 提供 TypeScript 插件开发 SDK,可自行开发画布节点插件,并使用 AI 生成、面板控制等扩展能力。
|
||||
- 内置 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
- 插件面板会显示插件版本,并对可升级的插件给出提醒。
|
||||
|
||||
Codex App 插件的安装与使用见 [Codex App 插件](/docs/overview/codex-app-plugin)。
|
||||
|
||||
## 提示词库
|
||||
|
||||
前台提示词库支持:
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"render",
|
||||
"docker",
|
||||
"third-party-prompt-repositories",
|
||||
"[在线体验](https://canvas.best/)",
|
||||
"local-codex-canvas"
|
||||
"[在线体验](https://canvas.best/)"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)每次刷新重新拉取、无需反复安装;需验证改源码刷新页面即生效。
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
title: 打赏支持
|
||||
description: 支持无限画布项目继续维护
|
||||
---
|
||||
|
||||
# 打赏支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
|
||||
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
|
||||
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -4,7 +4,6 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"security",
|
||||
"donate",
|
||||
"sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
---
|
||||
title: 广告赞助
|
||||
description: 广告赞助、项目露出和社区推广说明
|
||||
title: 赞助支持
|
||||
description: 赞助支持、广告合作、项目露出和社区推广说明
|
||||
---
|
||||
|
||||
# 广告赞助
|
||||
# 赞助支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过 Star、广告赞助合作、提供 AI 订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。相关支持会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 通过广告赞助合作支持项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
## 广告赞助
|
||||
|
||||
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
||||
|
||||
## 适合的赞助内容
|
||||
### 适合的赞助内容
|
||||
|
||||
- AI 工具、模型服务、API 服务或开发者工具。
|
||||
- 与图片、视频、文本生成工作流相关的产品。
|
||||
- 适合开源项目用户和 AI 创作者使用的服务。
|
||||
|
||||
## 建议提供的信息
|
||||
### 建议提供的信息
|
||||
|
||||
- 品牌或产品名称。
|
||||
- 希望展示的位置和周期。
|
||||
|
||||
+2
-3
@@ -27,8 +27,7 @@
|
||||
## 支持与安全
|
||||
|
||||
- [漏洞提交](/docs/support/security)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
- [赞助支持](/docs/support/sponsor)
|
||||
|
||||
## 项目进度
|
||||
|
||||
@@ -43,4 +42,4 @@
|
||||
|
||||
## 原理说明
|
||||
|
||||
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
|
||||
- [本地 Codex 连接画布原理](/docs/development/local-codex-canvas)
|
||||
|
||||
@@ -10,7 +10,7 @@ const tabs = [
|
||||
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
|
||||
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
||||
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
||||
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
|
||||
{ title: '赞助支持', href: '/docs/support/sponsor', prefix: '/docs/support' },
|
||||
];
|
||||
|
||||
export function DocsTopTabs() {
|
||||
|
||||
@@ -7,4 +7,9 @@ server {
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 由 nginx 官方镜像的入口在启动前自动执行(/docker-entrypoint.d/*.sh),随后 nginx 正常拉起。
|
||||
# 从环境变量生成运行期配置 config.js;每家统计一个独立变量,未设置的留空,
|
||||
# 前端据此判定该家「关闭」,不加载对应脚本、不发外部请求。可同时启用多家。
|
||||
|
||||
# GA4 / 百度 ID 只含字母、数字和连字符;过滤掉其它字符,
|
||||
# 避免值里的引号等破坏 config.js 的 JS 字符串(纵深防御)。
|
||||
sanitize_id() {
|
||||
printf '%s' "$1" | tr -cd 'A-Za-z0-9-'
|
||||
}
|
||||
|
||||
GA4_ID=$(sanitize_id "${ANALYTICS_GA4_ID:-}")
|
||||
BAIDU_ID=$(sanitize_id "${ANALYTICS_BAIDU_ID:-}")
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<EOF
|
||||
window.__RUNTIME_CONFIG__ = {
|
||||
ANALYTICS_GA4_ID: "${GA4_ID}",
|
||||
ANALYTICS_BAIDU_ID: "${BAIDU_ID}"
|
||||
};
|
||||
EOF
|
||||
@@ -17,6 +17,7 @@
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script src="/config.js"></script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// 运行期配置。容器启动时由 docker-entrypoint.sh 从环境变量重新生成此文件;
|
||||
// 本地开发与未经 entrypoint 处理时使用这份默认空配置(统计默认关闭)。
|
||||
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||
@@ -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";
|
||||
@@ -63,7 +63,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<CanvasResourceMentionTextarea
|
||||
<CanvasPromptChipInput
|
||||
value={prompt}
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
|
||||
@@ -26,7 +26,6 @@ type CanvasNodeProps = {
|
||||
editRequestNonce?: number;
|
||||
showPanel: boolean;
|
||||
showImageInfo: boolean;
|
||||
resourceLabel?: CanvasResourceReference;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
pluginHost?: CanvasPluginHost;
|
||||
registryVersion?: number;
|
||||
@@ -89,7 +88,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
editRequestNonce = 0,
|
||||
showPanel,
|
||||
showImageInfo,
|
||||
resourceLabel,
|
||||
mentionReferences = [],
|
||||
pluginHost,
|
||||
renderPanel,
|
||||
@@ -313,39 +311,41 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
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"
|
||||
@@ -414,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}
|
||||
|
||||
@@ -568,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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 运行期配置读取层。
|
||||
// 优先级:window.__RUNTIME_CONFIG__(容器启动时由 entrypoint 注入)> 构建期 VITE_ 变量 > 默认值。
|
||||
// 这样既支持「同一镜像 docker run -e 配置」,也兼容自行 build 时的构建期注入。
|
||||
//
|
||||
// 统计按「每家一个独立变量」配置:填了谁就启用谁,可同时启用多家,默认全空即关闭。
|
||||
// 仅支持 GA4 与百度:两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本/内联 JS。
|
||||
|
||||
type RuntimeConfig = {
|
||||
ANALYTICS_GA4_ID?: string; // GA4 衡量 ID(G-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);
|
||||
|
||||
@@ -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 {
|
||||
/* 忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}。`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+308
-861
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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()),
|
||||
}));
|
||||
Vendored
+5
@@ -6,4 +6,9 @@ declare const __APP_RELEASES__: import("@/lib/release").ReleaseInfo[];
|
||||
interface ImportMetaEnv {
|
||||
// 逗号分隔的本地开发插件 URL,每次启动重新拉取(不缓存、不落库)
|
||||
readonly VITE_DEV_PLUGINS?: string;
|
||||
// 统计分析(可选,构建期注入):每家一个独立变量,填了谁就启用谁,可同时启用多家
|
||||
// GA4 衡量 ID(G-XXXX)
|
||||
readonly VITE_ANALYTICS_GA4_ID?: string;
|
||||
// 百度统计站点 ID
|
||||
readonly VITE_ANALYTICS_BAIDU_ID?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user