Compare commits

...

42 Commits

Author SHA1 Message Date
HouYunFei 3a4a4a6772 fix(deps): sync bun.lock with package.json for streamdown transitive deps 2026-07-15 11:59:59 +08:00
HouYunFei ee8f126da7 feat(id): replace crypto.randomUUID with nanoid for unique ID generation 2026-07-15 11:55:36 +08:00
HouYunFei a4dcc679c9 feat(version): bump version to 0.7.0 2026-07-14 17:37:57 +08:00
HouYunFei b16263eb43 feat(version): bump version to 0.2.0 and update image ratio parsing functions 2026-07-14 17:37:51 +08:00
HouYunFei a5824f509e feat(agent): enhance local Agent connection handling with silent mode and update UI instructions 2026-07-14 17:08:20 +08:00
HouYunFei 7e0288d461 feat(docs): refine Infinite Canvas usage instructions and clarify local Agent connection process 2026-07-14 16:24:22 +08:00
HouYunFei 228ca29c82 feat(docs): update Infinite Canvas usage instructions and streamline local Agent connection process 2026-07-13 17:23:36 +08:00
HouYunFei 301fbce472 feat(canvas): add double-click functionality to create nodes from blank canvas area 2026-07-13 10:43:37 +08:00
HouYunFei ebd8ae2de7 feat(agent): clarify local Agent connection instructions for Codex plugin and manual MCP usage 2026-07-09 17:49:40 +08:00
HouYunFei 864ce4101a feat(agent): implement streamdown for real-time message rendering in chat 2026-07-09 17:16:35 +08:00
HouYunFei 25e1d173e8 feat(docs): add local development and canvas data structure documentation 2026-07-09 17:14:46 +08:00
HouYunFei 52a5e9e33a feat(agent): update documentation for new site tools and changelog guidelines 2026-07-09 16:54:48 +08:00
HouYunFei 2adbe9e43a feat(agent): add site tools support for canvas project listing and media generation 2026-07-09 16:53:09 +08:00
HouYunFei 7b347729ec feat(agent): implement global Agent panel with site navigation and interrupt functionality 2026-07-09 16:19:32 +08:00
HouYunFei 387d8be961 feat(canvas): enhance group node functionality with improved click and drag behavior 2026-07-09 13:31:13 +08:00
HouYunFei ec0e8c4e24 feat(canvas): add group node support with drag-and-drop functionality 2026-07-09 12:54:36 +08:00
HouYunFei abe3be58a6 feat(canvas): add group node support with drag-and-drop functionality 2026-07-09 11:48:23 +08:00
HouYunFei 050b36fad1 chore(changelog): add release notes for v0.6.0 2026-07-09 10:42:13 +08:00
HouYunFei 668c3adb52 chore(changelog): add release notes for v0.6.0 2026-07-09 10:21:07 +08:00
HouYunFei f2a36e86b2 fix(video): enhance video generation error handling and URL retrieval 2026-07-08 22:23:18 +08:00
HouYunFei d747527e2a Merge branch 'fork/beetle0915/fix/ime-enter-composition' into dev 2026-07-08 16:30:38 +08:00
HouYunFei 798d06b662 Merge branch 'fork/1797127235/fix/canvas-mode-model-switch' into dev
# Conflicts:
#	CHANGELOG.md
#	docs/content/docs/progress/pending-test.mdx
#	web/src/components/canvas/canvas-config-node-panel.tsx
#	web/src/components/canvas/canvas-node-prompt-panel.tsx
2026-07-08 16:23:12 +08:00
HouYunFei 257dab5bac fix: resolve issue with Gemini image format not passing aspect ratio configuration 2026-07-08 16:18:12 +08:00
HouYunFei 81144ea009 fix: resolve issue with Gemini image format not passing aspect ratio configuration 2026-07-08 15:11:18 +08:00
beetle0915 bac571ef4e Fix Enter handling during IME composition 2026-07-07 08:24:34 +08:00
1797127235 965d416aeb fix(canvas): switch to correct default model when changing generation mode 2026-07-06 10:33:52 +08:00
HouYunFei 3080d6da10 feat: enhance Codex agent connection handling with automatic thread recovery and improved error logging 2026-07-05 23:40:54 +08:00
HouYunFei 6113986637 refactor: remove agent mode switch and streamline local agent usage in the canvas assistant panel 2026-07-05 17:04:02 +08:00
HouYunFei ca5faa1f4e feat: add GitHub Pages workflow for automatic deployment on version tag push 2026-07-05 16:51:27 +08:00
HouYunFei 0b69dfce46 feat: add support for draggable grid lines in image slicing, with options to add, delete, and reset horizontal/vertical lines 2026-07-05 16:08:23 +08:00
HouYunFei 65a91b22e1 feat: add Codex App plugin support with dedicated configuration page and connection settings 2026-07-05 15:12:42 +08:00
HouYunFei 9dd0dc28b2 refactor: remove credit cost calculations and related UI elements from canvas node panels 2026-07-05 13:44:42 +08:00
HouYunFei 522153b3e6 fix: resolve issue with image retry results not persisting after page refresh in the image workbench 2026-07-05 12:46:51 +08:00
HouYunFei 568f0f1838 feat: remove deprecated backend .env.example file and update deployment configurations 2026-07-05 11:41:20 +08:00
HouYunFei cebc3dc5c2 feat: update project structure for Vite and React Router, remove deprecated sources, and enhance Docker deployment 2026-07-05 11:38:42 +08:00
HouYunFei 443afab7bd refactor: remove "use client" directive from multiple files and update project structure to align with Vite and React Router 2026-06-26 17:24:48 +08:00
HouYunFei e635ec6908 feat: migrate from Next.js to Vite and React Router, updating routing and environment variable handling 2026-06-26 16:41:32 +08:00
HouYunFei ef7a218ba6 feat(docs): update documentation to reflect direct browser connections for third-party prompts and WebDAV 2026-06-26 15:56:55 +08:00
HouYunFei bd0ad0aebf feat(plugin): add Codex App plugin support and local agent integration 2026-06-23 17:35:43 +08:00
HouYunFei 8cbe00e348 feat(api): add support for Gemini API format and enhance channel configuration 2026-06-17 10:24:55 +08:00
soldier 6300f5fb26 Merge branch 'main' of github.com:basketikun/infinite-canvas 2026-06-16 18:46:34 +08:00
soldier 73090da95a feat: 生成增加停止功能 2026-06-16 18:44:20 +08:00
155 changed files with 22248 additions and 2949 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "infinite-canvas-local",
"interface": {
"displayName": "Infinite Canvas"
},
"plugins": [
{
"name": "infinite-canvas",
"source": {
"source": "local",
"path": "./plugins/infinite-canvas"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_DOC_URL=https://docs.canvas.best
+52
View File
@@ -0,0 +1,52 @@
name: GitHub Pages
on:
push:
tags: ["v*"]
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
- name: Install dependencies
working-directory: web
run: bun install --frozen-lockfile
- name: Build
working-directory: web
env:
VITE_BASE: /${{ github.event.repository.name }}/
run: bun run build
- name: Add SPA fallback
run: cp web/dist/index.html web/dist/404.html
- uses: actions/configure-pages@v5
- uses: actions/upload-pages-artifact@v3
with:
path: web/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- id: deployment
uses: actions/deploy-pages@v4
+4
View File
@@ -8,3 +8,7 @@ data/
*.tsbuildinfo
.DS_Store
.idea
web/dist
**/dist
web/public/plugins
+7 -18
View File
@@ -8,7 +8,7 @@
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
- 项目尚未上线,不需要兼容旧数据;本地存储结构调整时直接按新设计修改,不写旧字段兼容数据迁移兜底,除非用户明确要求。
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
- 不要改无关文件,不要顺手重构。
- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。
@@ -19,28 +19,18 @@
- 补充时写成明确、可执行的规则,避免只写模糊描述。
- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。
## 后端规范
- 后端使用 Go + Gin + GORM。
- `handler/` 只处理 HTTP 入参、调用 service、返回 `OK` / `Fail`
- `service/` 放业务逻辑、默认值、校验、时间、ID、鉴权等处理。
- `repository/` 只做数据库访问和 GORM 查询。
- `model/` 只定义数据结构、枚举和简单模型方法。
- 列表接口优先沿用 `model.Query``Normalize`、分页和标签筛选方式。
- 业务接口保持 `{ code, data, msg }` 的响应结构。
- 新增数据表时同步更新 `docs/backend-database.md`
## 前端规范
- 前端使用 Next.js App Router、React、TypeScript、Ant Design、Tailwind、Zustand。
- 前端使用 Vite、React、React Router、TypeScript、Ant Design、Tailwind、Zustand。
- 编写 Ant Design 相关代码时,参考 https://ant.design/llms-full.txt 理解组件 API、示例和设计规范,并优先结合项目当前 antd 版本与既有写法。
- API 请求统一放在 `web/src/services/api/`
- 外部服务请求统一放在 `web/src/services/api/`,由浏览器前端直连,不假设存在项目后端
- 全局或跨页面状态优先放在 `web/src/stores/`
- 已经放在全局 store 或全局 hook 中的状态/动作,组件需要时直接使用对应 store/hook,不要为了“纯组件”层层透传 props;避免一个组件传递过多参数。
- 全局组件、全局常量、全局配置等全局性质的内容不要作为 props 或参数层层传递;哪里需要就在哪里直接从对应全局入口获取。
- 多个页面重复出现的 UI 副作用动作,例如复制文本并提示、下载并提示、统一确认弹窗,优先抽成 `web/src/hooks/` 下的全局 hook;不要放进 store,除非它确实是需要共享/订阅的状态。
- 画布相关状态和组件放在 `web/src/app/(user)/canvas/` 内部
- 页面里只有一个主业务组件时直接写在 `page.tsx`,不要单独拆 `Manager` 组件再传一堆 props
- 路由页面放在 `web/src/pages/`,页面布局放在 `web/src/layouts/`,路由配置放在 `web/src/router.tsx`
- 画布页面放在 `web/src/pages/canvas/`,画布组件放在 `web/src/components/canvas/`,画布状态放在 `web/src/stores/canvas/`,画布工具函数放在 `web/src/lib/canvas/`
- 页面按目录组织,例如 `web/src/pages/image/index.tsx`;页面里只有一个主业务组件时直接写在对应页面入口中,不要单独拆 `Manager` 组件再传一堆 props。
- 不要新增只做简单转发的组件,例如只 `return <X>{children}</X>` 或只换个名字透传 props;直接在使用处使用真实组件或把逻辑写进当前文件。
- 页面私有 hook 放在对应页面目录下,例如 `admin/assets/use-admin-assets.ts`;只有多个页面真实复用的 hook 才放到外层 `hooks/`
- 管理后台页面私有组件放到各自页面目录的 `components/` 下,例如 `admin/assets/components/``admin/prompts/components/`;不要为了单页面使用放到 `admin/components/` 共享目录。
@@ -72,10 +62,9 @@
- 后续待办写到 `docs/content/docs/progress/todo.mdx`
- 已实现但还需要用户测试确认的事项写到 `docs/content/docs/progress/pending-test.mdx`
- `docs/content/docs/progress/pending-test.mdx` 用来记录这个版本实际做了哪些可测试变更;`CHANGELOG.md``Unreleased` 只保留对这些变更的版本级归纳,避免逐条照搬实现细节。
- 每次重大改动(新增/调整/删除功能、接口或工具,影响用户可感知行为)完成后,都要在 `CHANGELOG.md``Unreleased` 追加一条记录,按 `[新增]` / `[调整]` / `[修复]` / `[优化]` 前缀分类,用一句中文归纳;纯内部重构、格式化、无用户可感知影响的小改动可不记。
- 每次 todo 事项完成后,先从 `docs/content/docs/progress/todo.mdx` 移到 `docs/content/docs/progress/pending-test.mdx`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/content/docs/overview/features.mdx`
- 每次任务完成前,都要根据实际变更检查并更新 `docs/content/docs/progress/todo.mdx``docs/content/docs/progress/pending-test.mdx`;如果功能或待办没有变化,也要确认无需修改。
- 接口响应规则写到 `docs/content/docs/backend/api-response.mdx`
- 数据库结构写到 `docs/content/docs/backend/backend-database.mdx`
- 文档不要写过期日期;除非用户明确要求记录具体时间。
## 发版本流程
+42
View File
@@ -2,6 +2,48 @@
## Unreleased
## v0.7.1 - 2026-07-15
+ [修复] 修复通过 `crypto.randomUUID` 不可用导致页面白屏报错的问题,改用 nanoid 生成 id。
## v0.7.0 - 2026-07-14
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
+ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
+ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
+ [修复] 修复 Gemini 格式生图时因内置比例列表触发误报导致生成失败的问题。
## v0.6.0 - 2026-07-09
+ [新增] 新增Codex App插件支持。
+ [新增] 配置与用户偏好新增独立页面和 Codex 连接配置 Tab。
+ [新增] 新增GitHub Pages 前端静态站点发布 workflow。
+ [新增] 图片切图支持等分线直接拖拽调整,并可新增、删除和重置横向 / 纵向切图线。
+ [调整] Docker 运行镜像改为 nginx 静态托管。
+ [调整] 移除网站Agent模式,专注于连接Codex Agent操作画布
+ [修复] 修复生图工作台重试成功结果刷新后丢失的问题。
+ [修复] 修复 Gemini 调用格式生图未传递尺寸比例配置的问题。
+ [修复] 修复前端 TypeScript 构建报错。
+ [修复] 修复画布生成配置切换文本/视频/音频模式时模型仍显示为生图模型的问题。
+ [修复] 兼容中转站视频任务直接返回视频 URL 且没有 `/content` 接口的情况,并优化失败原因展示。
## v0.5.0 - 2026-07-05
+ [新增] 渠道兼容Gemini格式。
+ [调整] 前端从 Next.js 迁移到 Vite,项目改为静态前端构建。
+ [调整] 移除已 404 的 EvoLinkAI 提示词来源。
## v0.4.0 - 2026-06-16
+ [新增] 新增网页版Agent Loop模式。
+6 -15
View File
@@ -1,27 +1,18 @@
# 构建 Next.js 前端产物。
# 构建 Vite 前端产物。
FROM oven/bun:1.3.13 AS web-build
WORKDIR /app/web
COPY web/package.json web/bun.lock ./
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --cache-dir=/root/.bun/install/cache
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --cache-dir=/root/.bun/install/cache
COPY VERSION /app/VERSION
COPY CHANGELOG.md /app/CHANGELOG.md
COPY web ./
RUN bun run build
# 运行镜像:只启动 Next.js,AI 请求由浏览器前台直连用户自己的接口。
FROM node:22-bookworm-slim
# 运行镜像:只启动静态前端,AI 请求由浏览器前台直连用户自己的接口。
FROM nginx:1.27-alpine
WORKDIR /app
COPY VERSION /app/VERSION
COPY CHANGELOG.md /app/CHANGELOG.md
COPY --from=web-build /app/web/public /app/web/public
COPY --from=web-build /app/web/.next/standalone /app/web
COPY --from=web-build /app/web/.next/static /app/web/.next/static
ENV NODE_ENV=production
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=web-build /app/web/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 3000
CMD ["sh", "-c", "cd /app/web && PORT=3000 node server.js"]
+26 -27
View File
@@ -8,16 +8,24 @@
<a href="https://linux.do/"><img src="https://img.shields.io/badge/Linux.do-Community-2b6de8?style=flat-square" alt="Linux.do"></a>
<a href="https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas"><img src="https://img.shields.io/badge/Render-Deploy-46e3b7?style=flat-square&logo=render&logoColor=111111" alt="Deploy to Render"></a>
<a href="https://github.com/basketikun/infinite-canvas"><img src="https://img.shields.io/github/stars/basketikun/infinite-canvas?style=flat-square&logo=github" alt="GitHub stars"></a>
<a href="VERSION"><img src="https://img.shields.io/badge/version-v0.2.0-2563eb?style=flat-square" alt="Version"></a>
<a href="https://github.com/basketikun/infinite-canvas/tags"><img src="https://img.shields.io/github/v/tag/basketikun/infinite-canvas?style=flat-square&label=version" alt="Version"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-f97316?style=flat-square" alt="License"></a>
<a href="https://vercel.com/"><img src="https://img.shields.io/badge/Vercel-ready-000000?style=flat-square&logo=vercel" alt="Vercel ready"></a>
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/Next.js-16.2-000000?style=flat-square&logo=nextdotjs" alt="Next.js"></a>
<a href="https://vite.dev/"><img src="https://img.shields.io/badge/Vite-7-646cff?style=flat-square&logo=vite&logoColor=white" alt="Vite"></a>
<a href="https://reactrouter.com/"><img src="https://img.shields.io/badge/React_Router-7-ca4245?style=flat-square&logo=reactrouter&logoColor=white" alt="React Router"></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/50077?utm_source=trendshift-badge&amp;utm_medium=badge&amp;utm_campaign=badge-trendshift-50077" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/50077/daily?language=TypeScript" alt="basketikun%2Finfinite-canvas | Trendshift" width="250" height="55"></a>
</p>
<p align="center">
<a href="docs/content/docs/overview/quick-start.mdx">快速开始</a> · <a href="docs/content/docs/overview/features.mdx">功能介绍</a> · <a href="docs/content/docs/overview/render.mdx">Render 部署</a> · <a href="docs/content/docs/overview/docker.mdx">Docker 部署</a> · <a href="docs/content/docs/canvas/canvas-node-manual.mdx">画布节点操作手册</a> · <a href="docs/content/docs/canvas/canvas-shortcuts.mdx">画布快捷键</a> · <a href="CLA.md">贡献者协议</a> · <a href="SECURITY.md">漏洞提交</a> · <a href="docs/content/docs/progress/todo.mdx">待办事项</a> · <a href="canvas-agent/README.md">本地 Canvas Agent</a> · <a href="plugins/infinite-canvas">Codex app 插件</a>
</p>
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
> [!CAUTION]
> 项目目前处于开发阶段,不保证历史数据兼容。各种数据库结构和存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用。
> 项目目前处于开发阶段,不保证历史数据兼容。各种本地存储格式都可能直接调整,欢迎关注后续更新,当前更适合个人/本地部署,不建议直接公网多人共用。
>
> 如果你需要稳定维护自己的分支,建议自行 fork 后独立开发。二次开发与 PR 请保留原作者信息和前端页面标识。
@@ -26,22 +34,19 @@
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布
- 提示词库:Next.js route 抓取多个 GitHub 开源项目,并缓存在运行实例内存中
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
如果你在为担心没有合适的生图API来发愁,可以查看该免费生图项目:[chatgpt2api](https://github.com/basketikun/chatgpt2api)
## 技术栈
- 前端:Next.js、React、TypeScript、Tailwind CSS、Ant Design、Zustand、TanStack Query。
- 少量 Next.js Route:第三方提示词内存缓存、WebDAV 可选代理。
- 部署:Vercel 或 Docker。
## 快速开始
推荐直接导入仓库到 Vercel,根目录已提供 `vercel.json`,会构建 `web/`AI API Key、Base URL、画布、素材和生成记录默认保存在浏览器本地。
AI API Key、Base URL、画布、素材和生成记录默认保存在浏览器本地。
### 本地开发
```bash
git clone git@github.com:basketikun/infinite-canvas.git
@@ -51,11 +56,12 @@ bun install
bun run dev
```
Docker 运行
### Docker 运行
```bash
docker build -t infinite-canvas .
docker run --rm -p 3000:3000 infinite-canvas
git clone git@github.com:basketikun/infinite-canvas.git
cd infinite-canvas
docker compose up -d
```
运行后默认端口3000,可访问 `http://localhost:3000`
@@ -94,18 +100,11 @@ https://canvas.best?apiKey={key}&baseUrl={address}
</tr>
</table>
## 文档
## 联系方式
- [快速开始](docs/content/docs/overview/quick-start.mdx)
- [功能介绍](docs/content/docs/overview/features.mdx)
- [Render 部署](docs/content/docs/overview/render.mdx)
- [Docker 部署](docs/content/docs/overview/docker.mdx)
- [画布节点操作手册](docs/content/docs/canvas/canvas-node-manual.mdx)
- [画布快捷键](docs/content/docs/canvas/canvas-shortcuts.mdx)
- [贡献者协议](CLA.md)
- [漏洞提交](SECURITY.md)
- [待办事项](docs/content/docs/progress/todo.mdx)
- [本地 Canvas Agent](canvas-agent/README.md)
项目定制二次开发需求 / 生图 API 需求可联系。
邮箱:1844025705@qq.com · QQ1844025705
## 赞助支持
+1 -1
View File
@@ -1 +1 @@
v0.4.0
v0.7.1
+37 -1
View File
@@ -1,6 +1,6 @@
# Infinite Canvas Agent
本地 Canvas Agent 用来连接线上画布网页和用户电脑上的 Codex / Claude Code。
本地 Canvas Agent 用来连接画布网页和用户电脑上的 Codex / Claude Code。本地开发时优先连接 `http://localhost:3000`,不需要先使用线上站点。
## 启动
@@ -26,6 +26,8 @@ Connect token: xxxxxx
在画布右上角点击 `Agent`,填入地址和 token 后连接。
Codex app 插件会读取启动输出里的 Local URL 和 Connect token,并直接打开画布网页地址;Canvas Agent 不负责生成画布打开 URL。
Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接后,Canvas Agent 会记录该网页 Origin;之后其他 Origin 不能复用这个本地 Agent,除非用户清理 `~/.infinite-canvas/canvas-agent.json` 里的 `origins`
## 发布
@@ -38,6 +40,40 @@ Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接
如果希望 Codex 终端能直接操作画布,需要先把 Canvas Agent 注册成 Codex MCP。
直接运行 `npx -y @basketikun/canvas-agent` 只启动本地 Agent 服务,不会安装 MCP,也不会增加 Codex 工具上下文。只有安装 Codex app 插件,或手动执行 `codex mcp add` 后,`infinite-canvas` 工具才会进入 Codex 上下文;由于工具较多,不使用时建议移除。
通过插件安装时移除插件:
```bash
codex plugin remove infinite-canvas
```
手动添加 MCP 时移除 MCP
```bash
codex mcp remove infinite-canvas
```
### Codex app 插件
仓库内提供了 Codex app 插件:`plugins/infinite-canvas`。在 Codex app 中添加本仓库的 marketplace 后,可以安装 `Infinite Canvas` 插件;插件会注册同一个 `infinite-canvas` MCP,并带上画布操作说明。
添加本地 marketplace 时建议使用仓库绝对路径,避免 Codex 从其他工作目录解析失败:
```bash
cd /path/to/infinite-canvas
codex plugin marketplace add "$(pwd)"
codex plugin add infinite-canvas@infinite-canvas-local
```
插件默认通过 npm 启动 MCP;这个命令只提供 MCP 工具,不会把 MCP 写入全局配置,也不会在退出时自动卸载:
```bash
npx -y @basketikun/canvas-agent mcp
```
使用时可以直接在 Codex 里说“打开 Infinite Canvas”,插件会启动本地 Agent,读取 Local URL 和 Connect token,然后在右侧打开 `https://canvas.best/` 并自动新建、连接画布;只有明确要求使用本地项目时才会启动本地前端。
Canvas Agent 启动后,给 Codex 添加 MCP
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@basketikun/canvas-agent",
"version": "0.1.0",
"version": "0.2.0",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
+42 -9
View File
@@ -30,13 +30,26 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
await codexQueue;
}
export function interruptCodexTurn() {
if (!codexApp) return false;
return codexApp.interruptCurrentTurn();
}
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
let files: string[] = [];
try {
files = await writeAttachmentFiles(attachments);
codexApp ||= await CodexAppClient.start(emit);
const threadId = await ensureCodexThread(codexApp, options);
await codexApp.startTurn(threadId, prompt, files);
let threadId = await ensureCodexThread(codexApp, options, emit);
try {
await codexApp.startTurn(threadId, prompt, files);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
codexThreadId = "";
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit);
await codexApp.startTurn(threadId, prompt, files);
}
} catch (error) {
emit("agent_error", { message: errorMessage(error) });
} finally {
@@ -96,14 +109,20 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) {
pipeJsonLines(child, emit, "claude");
}
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
if (options.threadId) {
const result = await app.readThread(options.threadId, false);
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd);
assertThreadWorkspace(thread, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
return codexThreadId;
if (options.threadId === codexThreadId) return codexThreadId;
try {
const result = await app.readThread(options.threadId, false);
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd);
assertThreadWorkspace(thread, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
return codexThreadId;
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
}
}
if (!codexThreadId) {
const thread = await app.startThread(options.cwd);
@@ -112,6 +131,10 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions)
return codexThreadId;
}
function isRecoverableThreadError(error: unknown) {
return /thread not loaded|no rollout found/i.test(errorMessage(error));
}
class CodexAppClient {
private nextId = 1;
private buffer = "";
@@ -182,6 +205,16 @@ class CodexAppClient {
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
}
interruptCurrentTurn() {
if (this.activeTurns.size === 0) return false;
try {
this.child.kill("SIGINT");
return true;
} catch {
return false;
}
}
private request(method: string, params: unknown) {
const id = this.nextId++;
this.write({ id, method, params });
+19 -2
View File
@@ -7,6 +7,18 @@ import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
const SITE_TOOLS = new Set<ToolName>([
"site_navigate",
"canvas_list_projects",
"workbench_image_get_config",
"workbench_image_generate",
"workbench_video_get_config",
"workbench_video_generate",
"prompts_search",
"assets_list",
"assets_add",
]);
export class CanvasSession {
private clients = new Map<string, ServerResponse>();
private pending = new Map<string, PendingRequest>();
@@ -18,13 +30,14 @@ export class CanvasSession {
openEvents(url: URL, res: ServerResponse) {
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
const statusOnly = url.searchParams.get("role") === "status";
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
this.clients.set(clientId, res);
if (!statusOnly) this.clients.set(clientId, res);
sendEvent(res, "hello", { ok: true, clientId });
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
res.on("close", () => {
clearInterval(timer);
this.clients.delete(clientId);
if (!statusOnly) this.clients.delete(clientId);
if (this.canvasState?.clientId === clientId) this.canvasState = null;
});
}
@@ -48,6 +61,10 @@ export class CanvasSession {
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
let tool: ToolName = name;
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
if (SITE_TOOLS.has(tool)) {
if (!this.clients.size) throw new Error("当前没有已连接网页");
return await this.requestCanvasTool(tool, input);
}
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
+15 -21
View File
@@ -7,10 +7,10 @@ export const DEFAULT_PORT = 17371;
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
export const VERSION = readPackageVersion();
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
export function loadConfig(create = false): CanvasAgentConfig {
try {
@@ -27,30 +27,28 @@ export function saveConfig(config: CanvasAgentConfig) {
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
export function ensureCanvasWorkspace(config: CanvasAgentConfig, canvasId: string) {
const id = safeSegment(canvasId || "default");
config.canvases ||= {};
const current = config.canvases[id];
export function ensureSiteWorkspace(config: CanvasAgentConfig) {
const current = config.workspace;
if (current?.workspacePath) {
fs.mkdirSync(resolveWorkspacePath(current.workspacePath), { recursive: true });
return { canvasId: id, ...current, workspacePath: resolveWorkspacePath(current.workspacePath) };
const workspacePath = resolveWorkspacePath(current.workspacePath);
fs.mkdirSync(workspacePath, { recursive: true });
return { ...current, workspacePath };
}
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id);
config.canvases[id] = { workspacePath };
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
config.workspace = { workspacePath };
fs.mkdirSync(workspacePath, { recursive: true });
saveConfig(config);
return { canvasId: id, workspacePath };
return { workspacePath };
}
export function updateCanvasWorkspace(config: CanvasAgentConfig, canvasId: string, patch: Partial<CanvasWorkspaceConfig>) {
const current = ensureCanvasWorkspace(config, canvasId);
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
const current = ensureSiteWorkspace(config);
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
const next = { ...current, ...patch, workspacePath };
config.canvases ||= {};
config.canvases[current.canvasId] = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
fs.mkdirSync(workspacePath, { recursive: true });
saveConfig(config);
return { canvasId: current.canvasId, ...config.canvases[current.canvasId] };
return config.workspace;
}
function resolveWorkspacePath(value: string) {
@@ -59,10 +57,6 @@ function resolveWorkspacePath(value: string) {
return path.resolve(value);
}
function safeSegment(value: string) {
return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "default";
}
function readPackageVersion() {
try {
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
+23 -17
View File
@@ -1,8 +1,8 @@
import express, { type NextFunction, type Request, type Response } from "express";
import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
import { CanvasSession } from "./canvas-session.js";
import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
import { archiveCodexThread, interruptCodexTurn, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
import type { AgentAttachment } from "./types.js";
export function startHttpServer() {
@@ -38,56 +38,60 @@ export function startHttpServer() {
res.json({ ok: true });
});
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
app.get("/agent/codex/workspace", (req, res) => {
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
app.get("/agent/codex/workspace", (_req, res) => {
const workspace = ensureSiteWorkspace(config);
res.json({ ok: true, workspace });
});
app.get("/agent/codex/threads", route(async (req, res) => {
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
res.json({ ok: true, workspace, ...result });
}));
app.post("/agent/codex/threads/new", route(async (req, res) => {
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
app.post("/agent/codex/threads/new", route(async (_req, res) => {
const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath);
const activeThreadId = String((thread as Record<string, unknown>).id || "");
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId });
updateSiteWorkspace(config, { activeThreadId });
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
}));
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
}));
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
updateSiteWorkspace(config, { activeThreadId: threadId });
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
}));
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
await archiveCodexThread(emit, threadId, workspace.workspacePath);
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined });
res.json({ ok: true });
}));
app.post("/agent/codex/turn", route(async (req, res) => {
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
const workspace = ensureSiteWorkspace(config);
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath);
threadId = String((thread as Record<string, unknown>).id || "");
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
updateSiteWorkspace(config, { activeThreadId: threadId });
} else if (threadId !== workspace.activeThreadId) {
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
updateSiteWorkspace(config, { activeThreadId: threadId });
}
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
res.json({ ok: true, threadId });
}));
app.post("/agent/codex/interrupt", (_req, res) => {
const ok = interruptCodexTurn();
res.json({ ok });
});
app.post("/agent/claude/turn", (req, res) => {
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
res.json({ ok: true });
@@ -99,7 +103,9 @@ export function startHttpServer() {
console.log("Infinite Canvas Agent");
console.log(`Local URL: ${config.url}`);
console.log(`Connect token: ${config.token}`);
console.log("Codex MCP: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
console.log("Codex MCP is not installed by this command.");
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
});
}
+27
View File
@@ -7,6 +7,8 @@ const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
export const toolNames = [
"site_navigate",
"canvas_list_projects",
"canvas_get_state",
"canvas_get_selection",
"canvas_export_snapshot",
@@ -30,6 +32,13 @@ export const toolNames = [
"canvas_select_nodes",
"canvas_set_viewport",
"canvas_run_generation",
"workbench_image_get_config",
"workbench_image_generate",
"workbench_video_get_config",
"workbench_video_generate",
"prompts_search",
"assets_list",
"assets_add",
] as const;
export type ToolName = (typeof toolNames)[number];
@@ -77,6 +86,8 @@ const generationFlowSchema = z.object({
});
export const toolInputSchemas = {
site_navigate: z.object({ path: z.string() }),
canvas_list_projects: z.object({ keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
canvas_get_state: z.object({}).passthrough(),
canvas_get_selection: z.object({}).passthrough(),
canvas_export_snapshot: z.object({}).passthrough(),
@@ -100,9 +111,18 @@ export const toolInputSchemas = {
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
canvas_set_viewport: z.object({ viewport: viewportSchema }),
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
workbench_image_get_config: z.object({}).passthrough(),
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
workbench_video_get_config: z.object({}).passthrough(),
workbench_video_generate: z.object({ prompt: z.string(), model: z.string().optional(), size: z.string().optional(), seconds: z.string().optional(), resolution: z.string().optional(), generateAudio: z.boolean().optional(), watermark: z.boolean().optional(), run: z.boolean().optional() }),
prompts_search: z.object({ keyword: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().optional(), pageSize: z.number().optional() }),
assets_list: z.object({ kind: z.enum(["all", "text", "image", "video"]).optional(), keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
assets_add: z.object({ kind: z.enum(["text", "image"]), title: z.string(), content: z.string().optional(), imageUrl: z.string().optional(), tags: z.array(z.string()).optional(), source: z.string().optional(), note: z.string().optional() }),
} satisfies Record<ToolName, z.AnyZodObject>;
export const toolDescriptions: Record<ToolName, string> = {
site_navigate: "跳转网站页面。path 可为 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image (生图工作台)、/video (视频创作台)、/prompts (提示词库)、/assets (我的素材)、/config (配置)。操作画布前若不在画布页,先用本工具打开画布。",
canvas_list_projects: "列出用户全部画布(仅标题、创建/更新时间、节点数、连线数,不含完整数据),支持 keyword 搜索和 page/pageSize 分页。返回的 id 可配合 site_navigate 跳转到 /canvas/:id 打开对应画布。",
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
canvas_get_selection: "读取当前网页画布选中的节点。",
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
@@ -126,4 +146,11 @@ export const toolDescriptions: Record<ToolName, string> = {
canvas_select_nodes: "设置当前选中节点。",
canvas_set_viewport: "调整画布视口。",
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、countrun 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermarkrun 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
assets_list: "列出用户「我的素材」,支持 kindtext/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
};
@@ -5,7 +5,7 @@ description: 画布本地存储、节点结构、媒体文件与清理机制
# 画布数据结构
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式。
## 当前存储位置
@@ -23,7 +23,7 @@ http://localhost:3000
## 2. 配置模型
打开右上角配置弹窗,填写自己的 `Base URL`、`API Key` 和模型名。第三方提示词由 Next.js route 拉取并缓存在运行实例内存中;WebDAV 可选择前端直连或 Next.js 转发
打开右上角配置弹窗,填写自己的 `Base URL`、`API Key` 和模型名。第三方提示词和 WebDAV 都由浏览器前端直连
## 3. 启动文档站
@@ -36,5 +36,5 @@ bun run dev
## 常见场景
- 改画布、页面和交互:主要看 `web/`
- 改提示词缓存或 WebDAV 代理:主要看 `web/src/app/api/` 和 `web/src/app/webdav-proxy/`
- 改提示词缓存:主要看 `web/src/services/api/prompts.ts`
- 改文档站内容:主要看 `docs/content/docs/`
+1 -1
View File
@@ -4,7 +4,7 @@
"pages": [
"overview",
"canvas",
"backend",
"development",
"progress",
"business",
"support"
@@ -0,0 +1,140 @@
---
title: Codex App 插件
description: 在 Codex App 中安装并使用 Infinite Canvas 插件
---
# Codex App 插件
Infinite Canvas 提供了一个 Codex App 插件,用来把 Codex 和本地 Canvas Agent 连接起来。安装后,你可以直接在 Codex 中说“打开 Infinite Canvas”,让 Codex 打开画布、读取节点、创建节点并触发生成流程。
插件源码位于仓库的 `plugins/infinite-canvas/` 目录。
## 安装方式
### 让 Codex 自动安装
把下面这段发给 Codex
```text
请从 https://github.com/basketikun/infinite-canvas.git 安装 Infinite Canvas Codex 插件。
请 clone 仓库到 ~/plugins/infinite-canvas,确认 plugins/infinite-canvas/.codex-plugin/plugin.json 存在,
把 plugins/infinite-canvas 加入 personal marketplace,先运行 codex plugin marketplace add ~
再运行 codex plugin add infinite-canvas@personal。
安装后请校验插件,并告诉我是否需要开启一个新对话来加载新技能和 MCP 工具。
```
这种方式适合不想手动改 marketplace 配置的用户。Codex 会完成 clone、写入 marketplace、安装插件和基础校验。
### 手动安装
推荐把仓库 clone 到 Codex personal marketplace 默认会引用的位置:
```bash
mkdir -p ~/plugins
git clone https://github.com/basketikun/infinite-canvas.git ~/plugins/infinite-canvas
```
确保 `~/.agents/plugins/marketplace.json` 中有 Infinite Canvas 条目。这里的 `path` 要指向仓库里的插件子目录:
```json
{
"name": "personal",
"interface": {
"displayName": "Personal"
},
"plugins": [
{
"name": "infinite-canvas",
"source": {
"source": "local",
"path": "./plugins/infinite-canvas/plugins/infinite-canvas"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
```
然后注册 personal marketplace 并安装插件:
```bash
codex plugin marketplace add ~
codex plugin add infinite-canvas@personal
```
安装完成后,建议新建一个 Codex 对话,让新的 skill 和 MCP 工具完整加载。
### 本仓库开发调试安装
如果你正在 Infinite Canvas 仓库中开发插件,可以直接使用仓库自带的 marketplace
```bash
cd /path/to/infinite-canvas
codex plugin marketplace add "$(pwd)"
codex plugin add infinite-canvas@infinite-canvas-local
```
仓库内的 `.agents/plugins/marketplace.json` 已经指向 `./plugins/infinite-canvas`,适合开发者本地调试。
## 使用插件
新建 Codex 对话后,可以直接说:
```text
打开 Infinite Canvas 新建画布 并连接好Codex
```
插件会启动本地 Canvas Agent,读取连接地址和 token,然后在右侧打开 `https://canvas.best/` 并自动新建、连接画布。只有明确要求使用本地项目时,插件才会启动本地前端。
画布打开后可以继续让 Codex 操作画布:
```text
读取当前画布并总结节点结构
根据选中节点创建一组生图提示词
把这些节点整理成一个生成流程
```
## 更新插件
普通业务代码变更通常不需要更新插件。只有改了下面这些内容时,才建议重新安装或刷新插件:
- `plugins/infinite-canvas/.codex-plugin/plugin.json`
- `plugins/infinite-canvas/.mcp.json`
- `plugins/infinite-canvas/skills/` 下的技能说明
- 插件暴露的 MCP、app 或入口脚本
重新安装后建议开启新对话,避免旧对话继续使用缓存中的技能和 MCP 工具。
## 手动排查
插件默认通过下面的 MCP 命令启动 Canvas Agent
```bash
npx -y @basketikun/canvas-agent mcp
```
如果需要手动排查,先启动画布:
```bash
cd web
bun install
bun run dev
```
然后启动本地 Agent
```bash
npx -y @basketikun/canvas-agent
```
从 Agent 输出或 `http://127.0.0.1:17371/config` 读取本地地址和 token 后,打开:
```text
<画布网页地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
```
`mode=new` 会让网页自动创建具体画布并连接本地 Agent,不需要手动点击“新建画布”。
+1 -1
View File
@@ -49,4 +49,4 @@ docker compose -f docker-compose.local.yml up -d --build
## 数据说明
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由 Next.js route 拉取后缓存在运行实例内存里,不需要额外挂载数据目录。
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
+2 -2
View File
@@ -125,7 +125,7 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
- 查看远程提示词源。
- 触发读取内置远程提示词源。
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库,由 Next.js route 拉取并缓存在当前运行实例内存中
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库,由浏览器前端直连拉取并缓存到 IndexedDB
## 素材
@@ -146,7 +146,7 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
- 当前版本不需要账号登录,也不再提供后台管理页面。
- 配置与用户偏好弹窗支持多个 OpenAI 兼容渠道、默认模型、生成偏好和 WebDAV 同步设置。
- 第三方提示词WebDAV 可使用少量 Next.js routeAI 接口不经过项目后端代理。
- 第三方提示词WebDAV 和 AI 接口都由浏览器前端直连,不经过项目后端代理。
## 当前限制
@@ -0,0 +1,173 @@
---
title: 本地 Codex 连接画布原理
description: 说明 Canvas Agent、MCP、SSE 和浏览器画布之间如何交互
---
# 本地 Codex 连接画布原理
本地 Codex 连接画布的核心不是让 Codex 直接控制浏览器,也不是模拟鼠标点击或操作 DOM,而是在用户电脑上运行一个 `Canvas Agent` 作为桥接服务。
```txt
Codex / 浏览器侧边栏
|
v
本机 Canvas Agent
|
v
浏览器画布 React 状态
```
真正的画布数据仍然在浏览器里,节点、连线、选区和视口都由前端 React 状态维护。Codex 只能通过 `Canvas Agent` 暴露的工具协议发出“读取画布”或“执行画布操作”的请求。
## 两个使用方向
### 1. 本地 Codex 主动连接画布
这个方向的入口在 Codex。
```txt
本地 Codex
-> infinite-canvas MCP 工具
-> 本机 Canvas Agent
-> 浏览器画布
```
用户在本地 Codex 里发起请求,例如读取画布、创建文本节点、连接节点或触发生成。Codex 调用 `infinite-canvas` MCP 工具,MCP 工具再请求本机 `Canvas Agent` 的 `/api/tools` 接口。
常见工具包括:
- `canvas_get_state`:读取当前画布摘要。
- `canvas_get_selection`:读取当前选区。
- `canvas_apply_ops`:批量执行画布操作。
- `canvas_create_text_node`:创建文本节点。
- `canvas_generate_image` / `canvas_generate_text`:创建生成流程并触发生成。
- `canvas_update_node` / `canvas_connect_nodes`:更新节点或连接节点。
这条链路适合在 Codex 对话里直接让 Codex 操作当前打开的画布。
### 2. 浏览器右侧面板主动打开 Codex
这个方向的入口在浏览器。
```txt
浏览器右侧 Codex 面板
-> 本机 Canvas Agent
-> Codex app-server --stdio
-> infinite-canvas MCP 工具
-> 本机 Canvas Agent
-> 浏览器画布
```
用户在网页右侧面板输入提示词后,浏览器把请求发送到 `Canvas Agent` 的 `/agent/codex/turn` 接口。`Canvas Agent` 会在本机启动或复用 `codex app-server --stdio`,并给这个 Codex 会话注入同一套 `infinite-canvas` MCP 工具。
所以即使入口在浏览器右侧面板,真正改动画布时仍然会回到同一条工具链路:
```txt
Codex -> MCP -> Canvas Agent -> 浏览器画布
```
右侧面板只是把“打开 Codex 会话、发送 prompt、展示流式事件”这件事搬到了网页里。
## 浏览器和 Canvas Agent 如何连接
`Canvas Agent` 默认监听本机地址:
```txt
http://127.0.0.1:17371
```
启动后会生成 `Connect token`,网页连接时必须携带 token。浏览器连接后会建立一条 SSE 长连接:
```txt
GET /events?token=xxx&clientId=xxx
```
连接成功后,`Canvas Agent` 会发送 `hello` 事件,浏览器把状态更新为已连接。
## 画布状态如何同步给 Codex
浏览器会把当前画布快照主动同步给 `Canvas Agent`
```txt
POST /canvas/state?token=xxx&clientId=xxx
```
快照包含:
- 当前画布 ID 和标题;
- 节点列表;
- 连线列表;
- 当前选中的节点 ID
- 当前视口位置和缩放。
`Canvas Agent` 把最近一次快照保存在内存里。Codex 调用 `canvas_get_state` 时,读取的是这份由浏览器同步过来的最新快照,而不是直接读取浏览器 DOM。
## Codex 如何操作画布
以创建文本节点为例,流程如下:
```txt
Codex 调 canvas_create_text_node
-> MCP POST /api/tools
-> Canvas Agent 转成 canvas_apply_ops
-> Canvas Agent 通过 SSE 发送 tool_call
-> 浏览器执行 add_node
-> 浏览器 POST /canvas/result
-> Canvas Agent 返回结果给 Codex
```
`Canvas Agent` 会把一些高级工具转换成统一的画布操作:
```json
{
"ops": [
{
"type": "add_node",
"nodeType": "text",
"metadata": {
"content": "文本内容",
"status": "success"
}
}
]
}
```
浏览器收到 `tool_call` 后,调用前端的 `applyCanvasAgentOps`,在 React 状态中执行 `add_node`、`update_node`、`delete_node`、`connect_nodes`、`set_viewport`、`select_nodes` 等操作。执行完成后再把结果通过 `/canvas/result` 回传给 `Canvas Agent`。
## 工具确认和撤销
为了避免 Codex 直接改动画布,浏览器侧对 `canvas_apply_ops` 默认有二次确认机制。收到写操作后,右侧面板会先展示待执行工具调用,用户确认后才真正应用到画布。
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
如果关闭工具确认,本地 Codex 可以直接执行画布工具调用,但仍然是通过同一套 `Canvas Agent -> SSE -> 浏览器执行` 的链路。
## 安全边界
- `Canvas Agent` 默认只监听 `127.0.0.1`。
- 网页连接必须携带本机生成的 `Connect token`。
- 第一次带正确 token 连接后,`Canvas Agent` 会记录网页 Origin,避免其他来源复用当前本机 Agent。
- Codex 不直接访问浏览器 DOM,也不直接持有画布内部状态。
- 线上网页不保存本机 Codex 登录态、API Key 或本地文件权限。
- 画布写操作最终由浏览器前端执行,因此可以在前端做确认、撤销和权限控制。
## 一句话总结
本地 Codex 连接画布的本质是:
```txt
Codex 调 MCP 工具,Canvas Agent 做本机桥接,浏览器通过 SSE 接收工具调用并在 React 状态中真正修改画布。
```
读状态是:
```txt
浏览器快照 -> Canvas Agent 内存 -> MCP 工具 -> Codex
```
写操作是:
```txt
Codex -> MCP 工具 -> Canvas Agent -> SSE tool_call -> 浏览器 applyOps -> /canvas/result -> Codex
```
+3 -1
View File
@@ -4,10 +4,12 @@
"defaultOpen": true,
"pages": [
"quick-start",
"codex-app-plugin",
"features",
"render",
"docker",
"third-party-prompt-repositories",
"[在线体验](https://canvas.best/)"
"[在线体验](https://canvas.best/)",
"local-codex-canvas"
]
}
+1 -1
View File
@@ -39,7 +39,7 @@ docker run --rm -p 3000:3000 infinite-canvas
## 首次使用建议
- 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。
- 如果需要提示词仓库内容,打开 `/prompts` 会通过 Next.js route 拉取并缓存在内存中
- 如果需要提示词仓库内容,打开 `/prompts` 会由浏览器前端直连拉取并缓存到 IndexedDB
- 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 WebDAV。
## 说明
+1 -1
View File
@@ -22,7 +22,7 @@ description: 使用 Render 部署无限画布
默认使用 Render 免费 Web Service
- 空闲约 15 分钟后会休眠,下次访问会自动唤醒。
- 当前主应用数据默认保存在浏览器本地;第三方提示词缓存会随 Render 实例重启而清空,下次访问会重新拉取。
- 当前主应用数据默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取。
- 适合体验和演示,不适合长期保存正式数据。
长期使用建议优先部署到 Vercel,或自行配置 WebDAV 同步浏览器本地数据。
@@ -5,3 +5,41 @@ description: 当前版本已实现但仍需人工验证的变更项
# 待测试
- 画布节点创建:鼠标左键双击画布空白区域会在点击位置打开节点选择菜单,可创建文本、图片、视频、音频、生成配置和组节点;双击已有节点或连线不应触发菜单。
- 本地 Agent 连接说明:新增提醒说明只有安装 Codex 插件或手动添加 MCP 后才会增加 Codex token 消耗,直接运行 `npx -y @basketikun/canvas-agent` 不会安装 MCP;需验证文案位置清晰。
- 本地 Agent 连接说明:画布面板和配置页 Codex Tab 改为区分“Codex 插件启动画布”和“直接运行 Agent 后网页连接”两种方式,需验证文案和布局清晰。
- 全站 Agent:Agent 面板从画布页抽离为全站常驻面板,挂在全局布局右侧,开/关只挤压左侧内容不遮挡;顶栏和画布工具条各有一个开关按钮,需验证在首页、生图、视频、素材、提示词库、画布等页面都能打开面板并保持 SSE 连接不断开。
- 全站 Agent:新增 `site_navigate` 工具,Agent 可直接跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`;导航直接执行不走工具确认,需验证跳转正确且面板不消失。
- 全站 Agent:画布工具(canvas_apply_ops 等)仅在当前处于画布页时可用,不在画布页调用会返回“当前不在画布页,请先用 site_navigate 打开画布”,需验证 Agent 会先导航到画布再执行画布操作。
- 全站 Agent:Codex 会话改为单一站点级连续线程(不再按 canvasId 分),跨页面/跨画布保持同一条对话上下文,历史记录即这条线程;本地 Agent workspace 从按画布分改为固定 `site` workspace`~/.infinite-canvas/codex-workspaces/site`),已装 `@basketikun/canvas-agent` 的用户需更新到新版本才生效。
- 全站 Agent:移除仅前端调用 OpenAI responses 接口的在线画布助手死代码(含 `requestToolResponse`),Agent 统一走 MCP + 本地 Codex 链路。
- 画布节点名称:每个节点保留 `title` 名称字段,默认名称显示在节点上方,双击名称可直接编辑,节点信息弹窗和 JSON 可查看该字段。
- 画布节点工具条:节点顶部工具条改为点击选中节点后显示,单纯鼠标移入 / 移出节点不应再频繁弹出。
- 画布组节点:组内子节点优先响应点击和拖拽;当鼠标位于组内节点上时,应移动 / 编辑该节点,而不是移动整个组。
- 画布组节点:工具栏新增“组”节点,普通节点拖入组区域会高亮目标组,松手后自动吸附进组;拖出组区域会解除归属,拖动组节点会一起移动组内子节点。
- GitHub Pages:新增版本 tag 触发的前端静态站点自动发布 workflow,需在仓库 Pages 设置中选择 GitHub Actions 并验证路由刷新回退。
- 画布图片切图:行数 / 列数会生成可直接拖拽的等分切图线,并支持新增横向 / 纵向线、删除单条线、重置切图线和切片数量预览。
- Codex App 插件:精简插件 README 安装步骤,更新插件网站和作者信息,并将 `open-canvas` 调整为默认打开在线画布、仅按需启动本地前端;需重新安装后确认详情页和两种打开流程正确。
- 本地 Codex 连接画布文档:新增 Canvas Agent、MCP、SSE 与浏览器画布交互原理说明,需确认文档导航和描述是否清晰。
- 顶部导航:缩小桌面顶部导航栏高度,导航项下划线和内容垂直对齐需确认。
- 配置与用户偏好:新增独立顶部导航页面,并在页面和弹窗中复用同一套配置面板;新增 Codex Tab,展示插件安装和本地 Agent 启动步骤,可配置本地 Agent 地址、Connect token 和工具执行确认开关。
- Codex 全局连接:配置页可直接连接本地 Canvas Agent,连接状态同步到画布和顶部 OpenAI 状态图标,刷新后会按本地保存的地址和 token 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。
- 画布 AgentCodex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`。
- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话。
- 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent。
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。
- Gemini 生图:生图工作台和画布生图会把尺寸配置转换为 Gemini `responseFormat.image.aspectRatio` 传入,需验证 1:1、9:16、16:9 及自定义像素尺寸的输出比例。
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。
- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务。
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。
- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源。
- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转。
- 前端构建:修复 TypeScript 严格检查下的构建报错。
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
- 环境变量:移除过期的后端 `.env.example` 示例文件。
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
+2 -3
View File
@@ -25,11 +25,10 @@ description: 安全漏洞提交和负责任披露说明
- 清晰的复现步骤。
- 漏洞影响、攻击场景和触发条件。
- 已脱敏的日志、截图或 PoC。
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或代理接口。
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或接口请求
## 范围说明
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 代理、权限控制和供应链风险。
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 同步、权限控制和供应链风险。
第三方模型服务、托管平台、浏览器插件、用户自行泄露的 API Key、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。
+6 -2
View File
@@ -15,8 +15,8 @@
## 开发与数据
- [本地开发](/docs/backend/local-development)
- [画布数据结构](/docs/backend/canvas-data-structure)
- [本地开发](/docs/development/local-development)
- [画布数据结构](/docs/development/canvas-data-structure)
## 商务合作
@@ -40,3 +40,7 @@
- 当前画布项目和“我的素材”主要保存在浏览器本地,跨设备可自行配置 WebDAV 同步。
- AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
## 原理说明
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
+1 -1
View File
@@ -7,7 +7,7 @@ import { cn } from '@/lib/cn';
const tabs = [
{ title: '项目介绍', href: '/docs/overview/quick-start', prefix: '/docs/overview' },
{ title: '操作手册', href: '/docs/canvas/canvas-node-manual', prefix: '/docs/canvas' },
{ title: '开发文档', href: '/docs/backend/local-development', prefix: '/docs/backend' },
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
+5
View File
@@ -0,0 +1,5 @@
{
"framework": "nextjs",
"installCommand": "bun install",
"buildCommand": "bun run build"
}
+10
View File
@@ -0,0 +1,10 @@
server {
listen 3000;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
@@ -0,0 +1,32 @@
{
"name": "infinite-canvas",
"version": "0.1.0",
"description": "Use Codex to read and operate Infinite Canvas through the local Canvas Agent MCP server.",
"author": {
"name": "basketikun",
"url": "https://canvas.best/"
},
"homepage": "https://canvas.best/",
"repository": "https://github.com/basketikun/infinite-canvas",
"license": "AGPL-3.0",
"keywords": ["infinite-canvas", "canvas", "mcp", "agent"],
"skills": "./skills/",
"mcpServers": "./.mcp.json",
"interface": {
"displayName": "Infinite Canvas",
"shortDescription": "Use Codex to operate the current Infinite Canvas board.",
"longDescription": "Connects Codex to the local Canvas Agent MCP server so Codex can inspect the current Infinite Canvas board, create nodes, connect flows, and trigger generation through the web canvas confirmation flow.",
"developerName": "basketikun",
"category": "Productivity",
"capabilities": ["Canvas", "MCP", "Creative Workflow"],
"websiteURL": "https://canvas.best/",
"defaultPrompt": [
"打开 Infinite Canvas",
"读取当前画布并总结节点结构",
"根据选中节点创建一组生图提示词"
],
"brandColor": "#2563EB",
"composerIcon": "./assets/icon.png",
"logo": "./assets/icon.png"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"infinite-canvas": {
"command": "npx",
"args": ["-y", "@basketikun/canvas-agent", "mcp"],
"startup_timeout_sec": 20,
"tool_timeout_sec": 90
}
}
}
+31
View File
@@ -0,0 +1,31 @@
# Infinite Canvas Codex Plugin
让 Codex 可以打开并操作 Infinite Canvas。
## 安装
macOS / Linux
```bash
git clone https://github.com/basketikun/infinite-canvas.git
cd infinite-canvas
codex plugin marketplace add "$(pwd)"
codex plugin add infinite-canvas@infinite-canvas-local
```
Windows PowerShell
```powershell
git clone https://github.com/basketikun/infinite-canvas.git
cd infinite-canvas
codex plugin marketplace add "$PWD"
codex plugin add infinite-canvas@infinite-canvas-local
```
Windows CMD 将 `$PWD` 替换为 `%cd%`
安装后新建一个 Codex 任务,然后输入:
```text
帮我打开并连接到 Infinite Canvas
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,27 @@
---
name: canvas
description: 操作 Infinite Canvas 当前网页画布,读取节点、选区、创建文本节点、创建生成流程、连接节点或触发生成。
---
# Infinite Canvas
你正在帮助用户操作 Infinite Canvas 网页画布。需要理解或改动画布时,优先使用已配置的 `infinite-canvas` MCP 工具;不要让用户手动复制 JSON、URL 或 token。
## 工作流
- 如果用户还没有打开或连接网页画布,使用 `open-canvas` 技能打开 Infinite Canvas,不要要求用户手动复制 URL 或 token。
- 操作前先用 `canvas_get_state` 读取当前画布;如果用户明确提到选中内容、当前节点或“这个”,先用 `canvas_get_selection`
- 创建单个文本内容优先用 `canvas_create_text_node`
- 创建生成内容优先用 `canvas_generate_text``canvas_generate_image``canvas_generate_video``canvas_generate_audio`
- 需要把提示词、配置和生成节点串成流程时,使用 `canvas_create_generation_flow` 或项目已有的流程工具。
- 需要批量增删改、移动、连接节点或设置视口时,使用 `canvas_apply_ops`
- 不要模拟鼠标点击,不要要求用户手动复制 JSON。
- 写入画布的操作会由网页侧边栏做二次确认,按当前工具结果继续推进即可。
## 风格
- 页面文案和画布节点内容默认使用中文。
- 生成节点、配置节点和提示词节点要保持结构清晰,方便用户继续编辑。
- 批量创建节点时注意给节点留出间距,不要堆叠在同一个位置。
- 图片、视频、音频等媒体节点默认保留原始比例;只有用户明确要求自由变形时才改变比例。
- 生成流程尽量少而清楚,优先让用户一眼能看懂节点关系。
@@ -0,0 +1,58 @@
---
name: open-canvas
description: 打开 Infinite Canvas 在线或本地画布,并自动连接本地 Canvas Agent。用户要求打开、启动、进入或使用 Infinite Canvas 画布时使用。
---
# Open Infinite Canvas
默认打开在线版。只有用户明确要求使用本地项目时,才启动本地前端。
## 在线版
1. 启动本地 Canvas Agent 并保持运行:
```bash
npx -y @basketikun/canvas-agent
```
2. 从启动输出取得 `Local URL``Connect token`
3. 在 Codex 右侧浏览器打开:
```text
https://canvas.best/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
```
## 本地版
1. 在 Infinite Canvas 项目中启动前端,并使用 Vite 输出的 `Local` 地址:
```bash
cd web
bun install
bun run dev
```
2. 启动本地 Canvas Agent
```bash
npx -y @basketikun/canvas-agent
```
3. 从启动输出取得 `Local URL``Connect token`,在 Codex 右侧浏览器打开:
```text
<Vite Local 地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
```
## MCP 与连接地址
插件在新的 Codex 任务中加载时会自动启动 `npx -y @basketikun/canvas-agent mcp`。这个 MCP 进程负责提供画布工具,不提供网页连接服务;
上面启动的普通 Canvas Agent 负责提供 `Local URL``Connect token`。两个进程读取同一份本地配置,因此不需要用户手动填写地址或 token。
## 打开模式
用户没有明确指定打开方式时,始终使用 `mode=new` 新建画布。只有用户明确要求时才替换为:
- 最近画布:`mode=recent`
- 自己选择:`mode=choose`
+7
View File
@@ -0,0 +1,7 @@
{
"framework": "vite",
"installCommand": "cd web && bun install",
"buildCommand": "cd web && bun run build",
"outputDirectory": "web/dist",
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
+717 -126
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="zh-CN" class="font-sans">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="一个无限画布创作工具" />
<link rel="icon" href="/logo.svg" />
<title>无限画布</title>
<script>
try {
var s = JSON.parse(localStorage.getItem("infinite-canvas:theme_store") || "{}");
var t = s.state && s.state.theme === "light" ? "light" : "dark";
document.documentElement.classList.toggle("dark", t === "dark");
document.documentElement.style.colorScheme = t;
} catch (e) {}
</script>
</head>
<body class="bg-background text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-6
View File
@@ -1,6 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
-27
View File
@@ -1,27 +0,0 @@
import type { NextConfig } from "next";
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { parseChangelog } from "@/lib/release";
const webDir = dirname(fileURLToPath(import.meta.url));
const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
export default function nextConfig(phase: string): NextConfig {
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
const releases = parseChangelog(localChangelog);
return {
output: "standalone",
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
typescript: {
ignoreBuildErrors: true,
},
env: {
NEXT_PUBLIC_APP_VERSION: localVersion,
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
},
};
}
+18030
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -4,15 +4,15 @@
"private": true,
"type": "module",
"scripts": {
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
"build": "next build",
"start": "next start",
"dev": "vite --host 0.0.0.0 --port 3000",
"build": "vite build",
"typecheck": "tsc --noEmit",
"start": "vite preview --host 0.0.0.0 --port 3000",
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"@ant-design/nextjs-registry": "^1.3.0",
"@ant-design/pro-components": "3.0.0-beta.3",
"@codemirror/lang-json": "^6.0.2",
"@tanstack/react-query": "^5.100.9",
@@ -29,11 +29,13 @@
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
"nanoid": "^5.1.11",
"next": "16.2.3",
"radix-ui": "^1.4.3",
"react": "19.2.5",
"react-dom": "19.2.5",
"react-router": "^7.12.0",
"react-router-dom": "^7.12.0",
"shadcn": "^4.7.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
@@ -45,7 +47,9 @@
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
"@vitejs/plugin-react": "^5.2.0",
"prettier": "^3.8.3",
"typescript": "^5"
"typescript": "^5",
"vite": "^7.3.0"
}
}
-5
View File
@@ -1,5 +0,0 @@
import CanvasClientPage from "./canvas-client-page";
export default function CanvasPage() {
return <CanvasClientPage />;
}
File diff suppressed because it is too large Load Diff
@@ -1,104 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2 } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "../utils/canvas-image-data";
export type CanvasImageSplitParams = ImageSplitParams;
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
const maxGridSize = 12;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const total = params.rows * params.columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setImage(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
};
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="mt-1 text-sm opacity-60"> {total} </p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid rows={params.rows} columns={params.columns} />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{total} </span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
</Button>
</div>
</div>
</div>
</Modal>
);
}
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) {
return (
<label className="block space-y-2">
<span className="font-medium opacity-75">{label}</span>
<InputNumber className="w-full" min={1} max={maxGridSize} precision={0} value={value} onChange={onChange} />
</label>
);
}
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
return (
<div className="pointer-events-none absolute inset-0">
{Array.from({ length: columns - 1 }).map((_, index) => (
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
))}
{Array.from({ length: rows - 1 }).map((_, index) => (
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
))}
</div>
);
}
function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
}
@@ -1,65 +0,0 @@
import { create } from "zustand";
import type { CanvasAgentOp } from "../utils/canvas-agent-ops";
export type AgentChatRole = "user" | "assistant" | "system" | "tool" | "error";
export type AgentAttachment = { id: string; name: string; type: string; size: number; url: string; dataUrl: string };
export type AgentChatItem = { id: string; role: AgentChatRole; title?: string; text: string; meta?: string; detail?: unknown; attachments?: AgentAttachment[]; streamId?: string };
export type AgentEventLog = { id: string; time: string; title: string; text: string; raw?: unknown };
export type AgentPendingToolCall = { requestId: string; name: string; input?: { ops?: CanvasAgentOp[] } };
export type AgentThreadSummary = { id: string; preview: string; name?: string | null; cwd?: string; status?: string; source?: unknown; createdAt?: number; updatedAt?: number };
export type AgentPanelTab = "chat" | "setup" | "history" | "log";
type CanvasAgentStore = {
width: number;
url: string;
token: string;
connected: boolean;
enabled: boolean;
prompt: string;
attachments: AgentAttachment[];
sending: boolean;
waiting: boolean;
messages: AgentChatItem[];
eventLogs: AgentEventLog[];
threads: AgentThreadSummary[];
activeThreadId: string;
workspacePath: string;
loadingThreads: boolean;
activeTab: AgentPanelTab;
confirmTools: boolean;
activity: string;
connectError: string;
pendingTool: AgentPendingToolCall | null;
setAgentState: (patch: Partial<Omit<CanvasAgentStore, "setAgentState" | "addMessage" | "addEventLog" | "clearEventLogs">>) => void;
addMessage: (item: AgentChatItem) => void;
addEventLog: (item: AgentEventLog) => void;
clearEventLogs: () => void;
};
export const useCanvasAgentStore = create<CanvasAgentStore>((set) => ({
width: typeof window === "undefined" ? 440 : Number(localStorage.getItem("canvas-agent-panel-width")) || 440,
url: typeof window === "undefined" ? "http://127.0.0.1:17371" : localStorage.getItem("canvas-agent-url") || "http://127.0.0.1:17371",
token: typeof window === "undefined" ? "" : localStorage.getItem("canvas-agent-token") || "",
connected: false,
enabled: false,
prompt: "",
attachments: [],
sending: false,
waiting: false,
messages: [],
eventLogs: [],
threads: [],
activeThreadId: "",
workspacePath: "",
loadingThreads: false,
activeTab: "setup",
confirmTools: true,
activity: "就绪",
connectError: "",
pendingTool: null,
setAgentState: (patch) => set(patch),
addMessage: (item) => set((state) => ({ messages: [...state.messages.slice(-120), item] })),
addEventLog: (item) => set((state) => ({ eventLogs: [...state.eventLogs.slice(-160), item] })),
clearEventLogs: () => set({ eventLogs: [] }),
}));
-14
View File
@@ -1,14 +0,0 @@
"use client";
import type { ReactNode } from "react";
import { AppTopNav } from "@/components/layout/app-top-nav";
export default function UserLayout({ children }: { children: ReactNode }) {
return (
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
<AppTopNav />
<div className="min-h-0 flex-1 overflow-hidden">{children}</div>
</div>
);
}
-258
View File
@@ -1,258 +0,0 @@
import type { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Prompt = {
id: string;
title: string;
coverUrl: string;
prompt: string;
tags: string[];
category: string;
githubUrl: string;
preview: string;
createdAt: string;
updatedAt: string;
};
type PromptCategory = {
category: string;
githubUrl: string;
build: () => Promise<Omit<Prompt, "category" | "githubUrl">[]>;
};
const gptImage2RawBase = "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main";
const awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main";
const awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main";
const youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main";
const youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main";
const davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main";
const gptImage2CaseFiles = ["README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"];
const cacheTtlMs = 1000 * 60 * 60;
const categories: PromptCategory[] = [
{ category: "gpt-image-2-prompts", githubUrl: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", build: buildGptImage2Prompts },
{ category: "awesome-gpt-image", githubUrl: "https://github.com/ZeroLu/awesome-gpt-image", build: buildAwesomeGptImagePrompts },
{ category: "awesome-gpt4o-image-prompts", githubUrl: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", build: buildAwesomeGpt4oImagePrompts },
{ category: "youmind-gpt-image-2", githubUrl: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", build: () => buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2") },
{ category: "youmind-nano-banana-pro", githubUrl: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", build: () => buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro") },
{ category: "davidwu-gpt-image2-prompts", githubUrl: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", build: buildDavidWuGptImage2Prompts },
];
let memoryCache: { items: Prompt[]; fetchedAt: number } | null = null;
let loadingPrompts: Promise<Prompt[]> | null = null;
export async function GET(request: NextRequest) {
const params = request.nextUrl.searchParams;
const keyword = (params.get("keyword") || "").trim().toLowerCase();
const tags = params.getAll("tag").filter(Boolean);
const category = params.get("category") || "";
const page = Math.max(1, Number(params.get("page")) || 1);
const pageSize = Math.max(1, Math.min(100, Number(params.get("pageSize")) || 20));
const items = await getPrompts();
const withoutTagFilter = filterPrompts(items, { keyword, category, tags: [] });
const filtered = filterPrompts(items, { keyword, category, tags });
return Response.json({
items: filtered.slice((page - 1) * pageSize, page * pageSize),
tags: collectTags(withoutTagFilter),
categories: categories.map((item) => item.category),
total: filtered.length,
});
}
async function getPrompts() {
if (memoryCache && Date.now() - memoryCache.fetchedAt < cacheTtlMs) return memoryCache.items;
if (loadingPrompts) return loadingPrompts;
loadingPrompts = loadPrompts().finally(() => {
loadingPrompts = null;
});
return loadingPrompts;
}
async function loadPrompts() {
const settled = await Promise.all(
categories.map(async (category) => {
try {
const items = await category.build();
return items.map((item) => ({ ...item, category: category.category, githubUrl: category.githubUrl }));
} catch {
return [];
}
}),
);
const items = settled.flat();
memoryCache = { items, fetchedAt: Date.now() };
return items;
}
function filterPrompts(items: Prompt[], options: { keyword: string; category: string; tags: string[] }) {
return items.filter((item) => {
if (isActiveOption(options.category) && item.category !== options.category) return false;
if (options.tags.length && !options.tags.some((tag) => item.tags.includes(tag))) return false;
if (!options.keyword) return true;
return [item.title, item.prompt, item.category, ...item.tags].join(" ").toLowerCase().includes(options.keyword);
});
}
async function buildGptImage2Prompts() {
const data = (await fetchJson<{ records?: Array<{ title?: string; tweet_url?: string; image_dir?: string; category?: string; added_at?: string }> }>(gptImage2RawBase, "data/ingested_tweets.json")).records || [];
const cases = new Map<string, string>();
const markdowns = await Promise.all(gptImage2CaseFiles.map((file) => fetchText(gptImage2RawBase, file)));
markdowns.forEach((markdown) => collectGptImage2Cases(cases, markdown));
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
data.forEach((item) => {
const prompt = cases.get(item.tweet_url || "");
if (!item.title || !prompt || !item.image_dir) return;
const image = `${gptImage2RawBase}/${item.image_dir}/output.jpg`;
items.push({ id: `gpt-image-2-prompts-${leftPad(items.length + 1)}`, title: item.title, coverUrl: image, prompt, tags: tagsFromCategory(item.category || ""), preview: markdownPreview([image]), createdAt: item.added_at || "", updatedAt: item.added_at || "" });
});
return items;
}
function collectGptImage2Cases(cases: Map<string, string>, markdown: string) {
for (const match of markdown.matchAll(/### Case \d+: \[[^\]]+]\(([^)]+)\).*?\*\*Prompt:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/gs)) {
cases.set(match[1], match[2].trim());
}
}
async function buildAwesomeGptImagePrompts() {
const markdown = await fetchText(awesomeGptImageRawBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const section of splitBeforeHeading(markdown, "## ")) {
const tags = tagsFromHeading(firstMatch(section, /^##\s+(.+)$/m));
for (const block of splitBeforeHeading(section, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).replace(/\[([^\]]+)]\([^)]+\)/g, "$1").trim();
const prompt = firstMatch(block, /\*\*:\*\*\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGptImageRawBase, block);
items.push(defaultPrompt(`awesome-gpt-image-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", tags, markdownPreview(images)));
}
}
return items;
}
async function buildAwesomeGpt4oImagePrompts() {
const markdown = await fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+(.+)$/m).trim();
const prompt = firstMatch(block, /- \*\*\*\*\s*`(.*?)`/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(awesomeGpt4oImagePromptsBase, block);
items.push(defaultPrompt(`awesome-gpt4o-image-prompts-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", ["gpt4o"], markdownPreview(images)));
}
return items;
}
async function buildYouMindPrompts(baseUrl: string, idPrefix: string, modelTag: string) {
const markdown = await fetchText(baseUrl, "README_zh.md");
const items: Omit<Prompt, "category" | "githubUrl">[] = [];
for (const block of splitBeforeHeading(markdown, "### ")) {
const title = firstMatch(block, /^###\s+No\.\s*\d+:\s*(.+)$/m).trim();
const prompt = firstMatch(block, /#### .*?\s*\r?\n\s*```[\w-]*\r?\n(.*?)\r?\n```/s).trim();
if (!title || !prompt) continue;
const images = extractMarkdownImages(baseUrl, block);
items.push(defaultPrompt(`${idPrefix}-${leftPad(items.length + 1)}`, title, prompt, images[0] || "", youMindTags(title, modelTag), markdownPreview(images)));
}
return items;
}
async function buildDavidWuGptImage2Prompts() {
const data = await fetchJson<Array<{ id?: number; title_en?: string; title_cn?: string; category?: string; category_cn?: string; prompt?: string; note?: string; author?: string; source?: string; needs_ref?: boolean; image?: string }>>(davidWuGptImage2RawBase, "prompts.json");
return data
.map((item, index) => {
const title = (item.title_cn || item.title_en || "").trim();
const prompt = (item.prompt || "").trim();
if (!title || !prompt) return null;
const image = absoluteImage(davidWuGptImage2RawBase, item.image || "");
const preview = [item.title_en, item.note, image ? `![](${image})` : ""].filter(Boolean).join("\n\n");
return defaultPrompt(`davidwu-gpt-image2-prompts-${leftPad(item.id || index + 1)}`, title, prompt, image, davidWuTags(item), preview);
})
.filter((item): item is Omit<Prompt, "category" | "githubUrl"> => Boolean(item));
}
function defaultPrompt(id: string, title: string, prompt: string, coverUrl: string, tags: string[], preview: string): Omit<Prompt, "category" | "githubUrl"> {
return { id, title, coverUrl, prompt, tags, preview, createdAt: "", updatedAt: "" };
}
async function fetchText(baseUrl: string, file: string) {
const response = await fetch(`${baseUrl}/${file}`, { cache: "no-store" });
if (!response.ok) throw new Error(`${file} 拉取失败`);
return response.text();
}
async function fetchJson<T>(baseUrl: string, file: string) {
return JSON.parse(await fetchText(baseUrl, file)) as T;
}
function splitBeforeHeading(markdown: string, prefix: string) {
const blocks: string[] = [];
let current: string[] = [];
for (const line of markdown.split("\n")) {
if (line.startsWith(prefix) && current.length) {
blocks.push(current.join("\n"));
current = [];
}
current.push(line);
}
blocks.push(current.join("\n"));
return blocks;
}
function firstMatch(value: string, pattern: RegExp) {
return pattern.exec(value)?.[1] || "";
}
function extractMarkdownImages(baseUrl: string, markdown: string) {
return Array.from(markdown.matchAll(/!\[[^\]]*]\(([^)]+)\)/g), (match) => absoluteImage(baseUrl, match[1])).filter(Boolean);
}
function absoluteImage(baseUrl: string, image: string) {
if (!image) return "";
if (/^https?:\/\//i.test(image)) return image;
return `${baseUrl}/${image.replace(/^\.?\//, "")}`;
}
function tagsFromCategory(category: string) {
return splitTags(category.replace(/\s+Cases$/i, ""), /\s*(?:&|and)\s*/);
}
function tagsFromHeading(heading: string) {
return splitTags(heading.replace(/[^\p{L}\p{N}/& ]/gu, ""), /\s*(?:\/|&||)\s*/);
}
function youMindTags(title: string, modelTag: string) {
const [, prefix] = title.match(/^(.+?) - /) || [];
return [modelTag, ...tagsFromHeading(prefix || "")];
}
function davidWuTags(item: { category_cn?: string; category?: string; author?: string; source?: string; needs_ref?: boolean }) {
const tags = splitTags([item.category_cn, item.category, item.author, item.source].filter(Boolean).join("/"), /\//);
if (item.needs_ref) tags.push("需要参考图");
return tags;
}
function splitTags(value: string, pattern: RegExp) {
return value
.split(pattern)
.map((tag) => tag.trim().toLowerCase())
.filter(Boolean);
}
function markdownPreview(images: string[]) {
return images.filter(Boolean).map((image) => `![](${image})`).join("\n\n");
}
function collectTags(items: Prompt[]) {
return Array.from(new Set(items.flatMap((item) => item.tags).filter(Boolean)));
}
function leftPad(value: number) {
return String(value).padStart(4, "0");
}
function isActiveOption(value: string) {
return value && value !== "全部" && value !== "all";
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

-40
View File
@@ -1,40 +0,0 @@
import type { Metadata } from "next";
import Script from "next/script";
import { AntdRegistry } from "@ant-design/nextjs-registry";
import { AppProviders } from "@/components/layout/app-providers";
import "antd/dist/reset.css";
import "./globals.css";
import React from "react";
export const metadata: Metadata = {
title: "无限画布",
description: "一个无限画布创作工具",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="zh-CN" suppressHydrationWarning className="font-sans">
<body
className="bg-background text-foreground antialiased"
style={{
fontFamily: '"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
}}
>
<Script
id="theme-script"
strategy="beforeInteractive"
dangerouslySetInnerHTML={{
__html: `try{var s=JSON.parse(localStorage.getItem("infinite-canvas:theme_store")||"{}");var t=s.state&&s.state.theme==="light"?"light":"dark";document.documentElement.classList.toggle("dark",t==="dark");document.documentElement.style.colorScheme=t}catch(e){}`,
}}
/>
<AntdRegistry>
<AppProviders>{children}</AppProviders>
</AntdRegistry>
</body>
</html>
);
}
-59
View File
@@ -1,59 +0,0 @@
import { NextRequest } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const WEBDAV_PROXY_TIMEOUT_MS = 120000;
export async function POST(request: NextRequest) {
const target = request.headers.get("x-webdav-target") || "";
const method = (request.headers.get("x-webdav-method") || "GET").toUpperCase();
if (!target) return new Response("Missing x-webdav-target", { status: 400 });
let url: URL;
try {
url = new URL(target);
} catch {
return new Response("Invalid x-webdav-target", { status: 400 });
}
if (url.protocol !== "http:" && url.protocol !== "https:") return new Response("Unsupported WebDAV target", { status: 400 });
const headers = new Headers();
copyHeader(request, headers, "x-webdav-authorization", "Authorization");
copyHeader(request, headers, "x-webdav-depth", "Depth");
copyHeader(request, headers, "x-webdav-destination", "Destination");
copyHeader(request, headers, "x-webdav-overwrite", "Overwrite");
copyHeader(request, headers, "x-webdav-content-type", "Content-Type");
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), WEBDAV_PROXY_TIMEOUT_MS);
try {
const body = method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer();
console.log(`[webdav-proxy] ${method} ${url.href} ${body?.byteLength || 0}B`);
const response = await fetch(url, { method, headers, body: body?.byteLength ? body : undefined, signal: controller.signal });
console.log(`[webdav-proxy] ${method} ${url.href} -> ${response.status}`);
return new Response(method === "HEAD" ? null : response.body, {
status: response.status,
headers: responseHeaders(response.headers),
});
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return new Response("WebDAV proxy timeout", { status: 504 });
return new Response(error instanceof Error ? error.message : "WebDAV proxy error", { status: 502 });
} finally {
clearTimeout(timer);
}
}
function copyHeader(request: NextRequest, headers: Headers, from: string, to: string) {
const value = request.headers.get(from);
if (value) headers.set(to, value);
}
function responseHeaders(headers: Headers) {
const result = new Headers();
["content-type", "etag", "last-modified", "dav"].forEach((key) => {
const value = headers.get(key);
if (value) result.set(key, value);
});
return result;
}
+87
View File
@@ -0,0 +1,87 @@
import { useState, type PointerEvent as ReactPointerEvent } from "react";
import { Bot, PanelRightClose } from "lucide-react";
import { Button, Switch, Tooltip } from "antd";
import { motion } from "motion/react";
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { CANVAS_AGENT_PANEL_MOTION_MS, useAgentStore } from "@/stores/use-agent-store";
import { useThemeStore } from "@/stores/use-theme-store";
const PANEL_MOTION_SECONDS = CANVAS_AGENT_PANEL_MOTION_MS / 1000;
export function AgentPanel() {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const width = useAgentStore((state) => state.width);
const [resizing, setResizing] = useState(false);
const panelMounted = useAgentStore((state) => state.panelMounted);
const panelOpen = useAgentStore((state) => state.panelOpen);
const panelClosing = useAgentStore((state) => state.panelClosing);
const confirmTools = useAgentStore((state) => state.confirmTools);
const setAgentState = useAgentStore((state) => state.setAgentState);
const closePanel = useAgentStore((state) => state.closePanel);
const startResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = Math.min(760, Math.max(360, startWidth + startX - moveEvent.clientX));
setAgentState({ width: nextWidth });
};
const onUp = () => {
localStorage.setItem("canvas-agent-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
if (!panelMounted) return null;
return (
<motion.div
className="relative z-[70] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: panelOpen ? width + 1 : 0, opacity: panelOpen ? 1 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: panelClosing ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: panelClosing ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<button type="button" className="absolute inset-y-0 left-0 z-40 w-4 -translate-x-1/2 cursor-col-resize" onPointerDown={startResize} aria-label="调整右侧面板宽度" />
<header className="flex h-14 shrink-0 items-center justify-between border-b px-4" style={{ borderColor: theme.node.stroke }}>
<div className="flex min-w-0 items-center gap-2">
<span className="grid size-8 place-items-center rounded-lg">
<Bot className="size-4" />
</span>
<div className="min-w-0">
<div className="text-base font-semibold leading-5">Agent</div>
<div className="truncate text-xs" style={{ color: theme.node.muted }}></div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<label className="flex items-center gap-1.5 text-xs" style={{ color: theme.node.muted }}>
<Switch size="small" checked={confirmTools} onChange={(confirmTools) => setAgentState({ confirmTools })} />
</label>
<Tooltip title="收起对话">
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" style={{ color: theme.node.muted }} icon={<PanelRightClose className="size-4" />} onClick={closePanel} />
</Tooltip>
</div>
</header>
<CanvasLocalAgentPanel embedded />
</motion.aside>
</motion.div>
);
}
@@ -1,5 +1,3 @@
"use client";
import { type ReactNode } from "react";
import { ImageSettingsTheme } from "@/components/image-settings-panel";
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Empty, Input, Modal, Pagination, Tag } from "antd";
import { Search } from "lucide-react";
@@ -11,6 +9,7 @@ export type InsertAssetPayload = { kind: "text"; content: string; title: string
type Props = {
open: boolean;
defaultTab?: string;
onInsert: (payload: InsertAssetPayload) => void;
onClose: () => void;
};
@@ -1,14 +1,13 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Button, Tooltip } from "antd";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, UserRound, Wrench, X, XCircle } from "lucide-react";
import { ArrowUp, CheckCircle2, CircleAlert, ImagePlus, LoaderCircle, Square, UserRound, Wrench, X, XCircle } from "lucide-react";
import { Streamdown } from "streamdown";
import { isPlainEnterKey } from "@/lib/keyboard-event";
import { canvasThemes } from "@/lib/canvas-theme";
import type { LocalUser } from "@/stores/use-user-store";
export type CanvasAgentChatAttachment = { id: string; name: string; url: string };
export type CanvasAgentMode = "online" | "local";
export type CanvasAgentChatMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool" | "error";
@@ -17,6 +16,8 @@ export type CanvasAgentChatMessage = {
meta?: string;
detail?: unknown;
attachments?: CanvasAgentChatAttachment[];
/** Present while the message is actively streaming; cleared on completion. */
streamId?: string;
};
const WORKING_TEXT = "working...";
@@ -48,7 +49,11 @@ export function AgentChatMessage({ item, theme, user, onRejectTool, onApproveToo
<div className={`flex items-start gap-3 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? <AgentAvatar theme={theme} /> : null}
<div className={`min-w-0 max-w-[82%] text-sm leading-6 ${isUser ? "text-right" : "text-left"}`} style={{ color }}>
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
{isUser ? (
<div className="whitespace-pre-wrap break-words text-left">{item.text}</div>
) : (
<Streamdown animated isAnimating={!!item.streamId}>{item.text}</Streamdown>
)}
{item.attachments?.length ? <AgentMessageAttachments attachments={item.attachments} /> : null}
{item.meta ? <div className="mt-1 text-[11px] opacity-45">{item.meta}</div> : null}
</div>
@@ -154,6 +159,7 @@ export function AgentChatComposer({
theme,
onPromptChange,
onSubmit,
onStop,
onAddFiles,
onRemoveAttachment,
left,
@@ -166,6 +172,7 @@ export function AgentChatComposer({
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
onPromptChange: (value: string) => void;
onSubmit: () => void;
onStop?: () => void;
onAddFiles?: (files: FileList | File[] | null) => void | Promise<void>;
onRemoveAttachment?: (id: string) => void;
left?: ReactNode;
@@ -200,7 +207,7 @@ export function AgentChatComposer({
void onAddFiles(images);
}}
onKeyDown={(event) => {
if (event.key !== "Enter" || event.shiftKey || event.ctrlKey || event.metaKey) return;
if (!isPlainEnterKey(event)) return;
event.preventDefault();
void onSubmit();
}}
@@ -223,25 +230,19 @@ export function AgentChatComposer({
) : null}
{left}
</div>
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
<div className="flex shrink-0 items-center gap-1.5">
{sending && onStop ? (
<Button danger shape="circle" className="!h-10 !w-10 !min-w-10" icon={<Square className="size-4" />} onClick={() => void onStop()} aria-label="停止" />
) : (
<Button type="primary" shape="circle" className="!h-10 !w-10 !min-w-10" disabled={!canSubmit} icon={sending ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />} onClick={() => void onSubmit()} aria-label="发送" />
)}
</div>
</div>
</div>
</div>
);
}
export function AgentModeSwitch({ value, theme, onChange }: { value: CanvasAgentMode; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: CanvasAgentMode) => void }) {
return (
<div className="inline-flex shrink-0 rounded-lg border p-0.5 text-xs" style={{ borderColor: theme.node.stroke }}>
{(["online", "local"] as const).map((item) => (
<button key={item} type="button" className="rounded-md px-2 py-1 transition" style={{ background: value === item ? theme.node.fill : "transparent", color: value === item ? theme.node.text : theme.node.muted }} onClick={() => onChange(item)}>
{item === "online" ? "网站" : "本机"}
</button>
))}
</div>
);
}
export function AgentPanelTabs<T extends string>({ value, items, theme, right, onChange }: { value: T; items: { value: T; label: string; icon?: ReactNode; count?: number }[]; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; right?: ReactNode; onChange: (value: T) => void }) {
return (
<div className="border-b px-3" style={{ borderColor: theme.node.stroke }}>
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
import { Button, Image } from "antd";
@@ -1,18 +1,15 @@
"use client";
import type { CSSProperties } from "react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Square, Video } from "lucide-react";
import { Button, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "@/types/canvas";
type CanvasConfigNodePanelProps = {
node: CanvasNodeData;
@@ -20,17 +17,16 @@ type CanvasConfigNodePanelProps = {
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
onGenerate: (nodeId: string) => void;
onStop: (nodeId: string) => void;
onComposerToggle: () => void;
};
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onStop, onComposerToggle }: CanvasConfigNodePanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const mode = node.metadata?.generationMode || "image";
const config = buildNodeConfig(globalConfig, node, mode);
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? count : 1 });
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
@@ -113,17 +109,24 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
<Button
type="primary"
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
disabled={isRunning || !canGenerate}
danger={isRunning}
disabled={!isRunning && !canGenerate}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => onGenerate(node.id)}
onClick={() => (isRunning ? onStop(node.id) : onGenerate(node.id))}
>
<span className="inline-flex items-center gap-1.5">
<span className="inline-flex items-center gap-1">
<CreditSymbol />
{credits.toLocaleString()}
</span>
{isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <Play className="size-4" />}
<span></span>
{isRunning ? (
<>
<LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" />
<span></span>
</>
) : (
<>
<Play className="size-4" />
<span></span>
</>
)}
</span>
</Button>
</div>
@@ -141,9 +144,16 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(defaultModel, mode)
? defaultModel
: fallbackModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
@@ -2,7 +2,7 @@ import type { MouseEvent as ReactMouseEvent } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "@/types/canvas";
export function ConnectionPath({
connection,
@@ -1,12 +1,10 @@
"use client";
import { useEffect } from "react";
import type { ReactNode } from "react";
import { Plus, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ContextMenuState } from "../types";
import type { ContextMenuState } from "@/types/canvas";
export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }: { menu: ContextMenuState; onClose: () => void; onDuplicate: () => void; onDelete: () => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -27,8 +25,8 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
onPointerDown={(event) => event.stopPropagation()}
>
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="复制" onClick={onDuplicate} /> : null}
<MenuButton icon={<Trash2 className="size-4" />} label="删除" onClick={onDelete} danger />
</div>
);
}
@@ -1,10 +1,8 @@
"use client";
import { Button, Modal } from "antd";
import { useAssetStore } from "@/stores/use-asset-store";
import { useCanvasStore } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
export function CanvasDeleteProjectsDialog() {
const ids = useCanvasUiStore((state) => state.deleteProjectIds);
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd";
import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
@@ -102,10 +100,12 @@ export function ImageToolSettingsModal({
frames.push(firstFrame);
const timer = window.setTimeout(sync, 120);
const resizeObserver = typeof ResizeObserver !== "undefined" && toolbar ? new ResizeObserver(sync) : null;
resizeObserver?.observe(toolbar);
toolbar?.childNodes.forEach((child) => {
if (child instanceof Element) resizeObserver?.observe(child);
});
if (resizeObserver && toolbar) {
resizeObserver.observe(toolbar);
toolbar.childNodes.forEach((child) => {
if (child instanceof Element) resizeObserver.observe(child);
});
}
sync();
window.addEventListener("resize", syncPreviewScroll);
return () => {
@@ -1,9 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
import type { CanvasNodeData } from "../types";
import type { CanvasNodeData } from "@/types/canvas";
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
@@ -1,27 +1,27 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { App, Button, Input, Segmented, Tooltip } from "antd";
import copyToClipboard from "copy-to-clipboard";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, RotateCcw, Terminal, Trash2 } from "lucide-react";
import { motion } from "motion/react";
import { Copy, FolderOpen, History, KeyRound, Link2, LoaderCircle, PlugZap, Plus, RefreshCw, Square, Terminal, Trash2 } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { randomId } from "@/lib/utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { useCanvasAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "../stores/use-canvas-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
import { useAgentStore, type AgentAttachment, type AgentChatItem, type AgentEventLog, type AgentPanelTab, type AgentPendingToolCall, type AgentThreadSummary } from "@/stores/use-agent-store";
import { summarizeCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "@/lib/canvas/canvas-agent-ops";
import { isSiteTool, runSiteTool, SITE_TOOL_LABELS } from "@/lib/agent/agent-site-tools";
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentPendingToolCard, AgentWorkingMessage, type CanvasAgentChatAttachment } from "./canvas-agent-chat-ui";
const PANEL_MOTION_SECONDS = 0.5;
const MAX_ATTACHMENTS = 6;
const MAX_ATTACHMENT_PAYLOAD_BYTES = 28 * 1024 * 1024;
const DEFAULT_AGENT_URL = "http://127.0.0.1:17371";
const AGENT_CONNECT_STEPS = [
{ title: "1. 本机已安装并登录 Codex", text: "先确认本机终端里的 Codex 可以正常使用。", command: "codex --version" },
{ title: "2. 安装 Canvas Agent", text: "推荐全局安装,后续可以直接运行 canvas-agent。", command: "npm i -g @basketikun/canvas-agent" },
{ title: "3. 启动本地 Agent", text: "启动后终端会输出 Local URL 和 Connect token。", command: "canvas-agent" },
{ title: "4. 回到网页连接", text: "把终端输出的地址和 token 填到下面,点击连接。" },
{ title: "方式一:在 Codex 中使用插件", text: "在 Codex app 安装 Infinite Canvas 插件后,通过插件启动画布,插件会自动启动本地 Agent 并带上连接信息。" },
{ title: "方式二:直接运行 Agent", text: "不使用 Codex 插件时,在终端运行下面命令,再回到网页里连接或手动填入 Local URL 和 Connect token。", command: "npx -y @basketikun/canvas-agent" },
];
const AGENT_PLUGIN_REMOVE_COMMAND = "codex plugin remove infinite-canvas";
const AGENT_MCP_REMOVE_COMMAND = "codex mcp remove infinite-canvas";
type AgentEventPayload = {
agent?: string;
@@ -35,41 +35,43 @@ type AgentEventPayload = {
type AgentEventItem = { id?: string; type?: string; text?: unknown; message?: unknown; server?: string; tool?: string; status?: string; arguments?: unknown; result?: unknown; error?: { message?: string } };
type AgentLogContext = { endpoint: string; connected: boolean; enabled: boolean; activity: string; waiting: boolean; sending: boolean; messages: number; pendingTool?: string };
type AgentWorkspace = { canvasId: string; workspacePath: string; activeThreadId?: string };
type AgentWorkspace = { workspacePath: string; activeThreadId?: string };
type AgentThreadsResponse = { ok?: boolean; workspace?: AgentWorkspace; data?: AgentThreadSummary[] };
type AgentThreadResponse = { ok?: boolean; workspace?: AgentWorkspace; thread?: AgentThreadSummary; messages?: AgentChatItem[] };
type AgentConfigResponse = { ok?: boolean; url?: string; token?: string; hasToken?: boolean };
export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedded, onApplyOps, onUndoOps }: { snapshot: CanvasAgentSnapshot; canUndoOps: boolean; collapsed?: boolean; embedded?: boolean; onApplyOps: (ops: CanvasAgentOp[]) => unknown; onUndoOps: () => CanvasAgentSnapshot | null }) {
export function CanvasLocalAgentPanel({ embedded, headless, autoConnect }: { embedded?: boolean; headless?: boolean; autoConnect?: boolean }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const user = useUserStore((state) => state.user);
const { message, modal } = App.useApp();
const { width, url, token, connected, enabled, prompt, attachments, sending, waiting, messages, eventLogs, threads, activeThreadId, workspacePath, loadingThreads, activeTab, confirmTools, activity, connectError, pendingTool, setAgentState, addMessage: pushMessage, addEventLog: pushEventLog, clearEventLogs } = useCanvasAgentStore();
const [resizing, setResizing] = useState(false);
const [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();
const listRef = useRef<HTMLDivElement>(null);
const snapshotRef = useRef(snapshot);
const canvasContextRef = useRef(canvasContext);
const confirmToolsRef = useRef(confirmTools);
const pendingToolRef = useRef<AgentPendingToolCall | null>(null);
const onApplyOpsRef = useRef(onApplyOps);
const autoConnectRef = useRef(false);
const connectedRef = useRef(false);
const errorLoggedRef = useRef(false);
const attachmentUrlsRef = useRef(new Set<string>());
const clientIdRef = useRef(typeof crypto === "undefined" ? `${Date.now()}` : crypto.randomUUID());
const clientIdRef = useRef(randomId());
const endpoint = useMemo(() => url.trim().replace(/\/$/, ""), [url]);
const urlAgentAutoConnect = searchParams.has("agentUrl") && searchParams.has("agentToken");
const loadThreads = useCallback(async () => {
const projectId = snapshotRef.current.projectId;
if ((!connectedRef.current && !useCanvasAgentStore.getState().connected) || !projectId) return;
if (!connectedRef.current && !useAgentStore.getState().connected) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads?canvasId=${encodeURIComponent(projectId)}`);
const current = useCanvasAgentStore.getState();
const data = await fetchAgentJson<AgentThreadsResponse>(endpoint, token, `/agent/codex/threads`);
const nextThreadId = data.workspace?.activeThreadId || "";
setAgentState({
threads: data.data || [],
workspacePath: data.workspace?.workspacePath || current.workspacePath,
activeThreadId: data.workspace?.activeThreadId || current.activeThreadId,
workspacePath: data.workspace?.workspacePath || "",
activeThreadId: nextThreadId,
messages: [],
});
const nextThreadId = data.workspace?.activeThreadId || current.activeThreadId;
if (nextThreadId && !current.messages.length) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}?canvasId=${encodeURIComponent(projectId)}`);
if (nextThreadId) {
const thread = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(nextThreadId)}`);
setAgentState({ messages: normalizeHistoryMessages(thread.messages || []) });
}
} catch (error) {
@@ -80,17 +82,14 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}, [endpoint, setAgentState, token]);
useEffect(() => {
snapshotRef.current = snapshot;
}, [snapshot]);
canvasContextRef.current = canvasContext;
}, [canvasContext]);
useEffect(() => {
confirmToolsRef.current = confirmTools;
}, [confirmTools]);
useEffect(() => {
pendingToolRef.current = pendingTool;
}, [pendingTool]);
useEffect(() => {
onApplyOpsRef.current = onApplyOps;
}, [onApplyOps]);
useEffect(() => {
listRef.current?.scrollTo({ top: listRef.current.scrollHeight });
}, [messages, pendingTool, waiting]);
@@ -105,9 +104,9 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
source.addEventListener("hello", () => {
errorLoggedRef.current = false;
connectedRef.current = true;
setAgentState({ connected: true, activity: "已连接", connectError: "", messages: useCanvasAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, snapshotRef.current);
setAgentState({ connected: true, activity: "已连接", connectError: "", silentConnect: false, messages: useAgentStore.getState().messages.filter((item) => !isConnectionErrorMessage(item)) });
if (!headless) message.success("本地 Agent 已连接");
void postState(endpoint, token, clientId, canvasContextRef.current?.snapshot || null);
});
source.addEventListener("tool_call", (event) => {
const data = parseEventData<AgentPendingToolCall>(event);
@@ -133,14 +132,15 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
});
source.onerror = () => {
const wasConnected = connectedRef.current;
const silent = useAgentStore.getState().silentConnect && !wasConnected;
const text = wasConnected ? "本地 Agent 连接失败或已断开" : "连接失败,请检查地址和 token";
if (!errorLoggedRef.current || wasConnected) {
addEventLog(wasConnected ? "连接断开" : "连接失败", { endpoint, error: text });
message.error(text);
if (!headless && !silent) message.error(text);
}
errorLoggedRef.current = true;
connectedRef.current = false;
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: text });
clearAgentSession({ activity: wasConnected ? "连接断开" : "连接失败", connected: false, connectError: silent ? "" : text, silentConnect: false });
if (!wasConnected) {
source.close();
setAgentState({ enabled: false });
@@ -149,19 +149,18 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
return () => {
source.close();
connectedRef.current = false;
setAgentState({ connected: false });
};
}, [enabled, endpoint, loadThreads, message, setAgentState, token]);
useEffect(() => {
if (connected) void loadThreads();
}, [connected, loadThreads, snapshot.projectId]);
}, [connected, loadThreads]);
useEffect(() => {
if (!connected) return;
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, snapshot), 300);
const timer = setTimeout(() => void postState(endpoint, token, clientIdRef.current, canvasContext?.snapshot || null), 300);
return () => clearTimeout(timer);
}, [connected, endpoint, snapshot, token]);
}, [canvasContext?.snapshot, connected, endpoint, token]);
const sendPrompt = async () => {
const text = prompt.trim();
@@ -176,7 +175,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
addMessage({ role: "user", text: text || "发送了图片", attachments: files });
addEventLog("用户发送", { text, attachments: files.map(({ name, type, size }) => ({ name, type, size })) });
try {
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, canvasId: snapshotRef.current.projectId, threadId: useCanvasAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
const res = await fetch(`${endpoint}/agent/codex/turn?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ prompt: requestPrompt, threadId: useAgentStore.getState().activeThreadId || undefined, attachments: files.map(({ name, type, dataUrl }) => ({ name, type, dataUrl })) }) });
if (!res.ok) throw new Error("本地 Agent 拒绝了请求");
const data = (await res.json()) as { threadId?: string };
if (data.threadId) setAgentState({ activeThreadId: data.threadId });
@@ -195,10 +194,22 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}
};
const stopTurn = async () => {
if (!connected || (!sending && !waiting)) return;
setAgentState({ activity: "停止中" });
try {
await fetch(`${endpoint}/agent/codex/interrupt?token=${encodeURIComponent(token)}`, { method: "POST", headers: { "content-type": "application/json" } });
setAgentState({ activity: "已停止", sending: false, waiting: false });
addEventLog("用户停止", {});
} catch {
setAgentState({ activity: "就绪", sending: false, waiting: false });
}
};
const addAttachments = async (files: FileList | File[] | null) => {
if (!files) return;
const images = Array.from(files).filter((file) => file.type.startsWith("image/"));
const prev = useCanvasAgentStore.getState().attachments;
const prev = useAgentStore.getState().attachments;
try {
const next = await Promise.all(images.slice(0, Math.max(0, MAX_ATTACHMENTS - prev.length)).map(async (file) => {
const dataUrl = await readDataUrl(file);
@@ -245,16 +256,46 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
};
const runToolCall = async (endpoint: string, token: string, payload: AgentPendingToolCall) => {
if (isSiteTool(payload.name)) {
try {
setAgentState({ activity: SITE_TOOL_LABELS[payload.name], waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = await runSiteTool(payload.name, payload.input || {}, navigate);
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: siteToolSummary(payload.name, result), detail: { requestId: payload.requestId, name: payload.name, input: payload.input, result } });
} catch (error) {
const message = error instanceof Error ? error.message : "工具执行失败";
setAgentState({ activity: "工具失败", waiting: false });
addMessage({ role: "tool", title: "工具失败", text: message, detail: payload });
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, error: message });
}
return;
}
try {
const input: { ops?: CanvasAgentOp[] } = payload.input || {};
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : "读取画布", waiting: true });
const input: { ops?: CanvasAgentOp[]; path?: string } = payload.input || {};
setAgentState({ activity: payload.name === "canvas_apply_ops" ? "执行画布操作" : payload.name === "site_navigate" ? "跳转页面" : "读取画布", waiting: true });
addEventLog(toolName(payload.name), payload, payload);
const result = payload.name === "canvas_apply_ops" ? onApplyOpsRef.current(input.ops || []) : snapshotRef.current;
let result: unknown;
if (payload.name === "site_navigate") {
const path = input.path || "/";
navigate(path);
result = { ok: true, path };
} else if (payload.name === "canvas_apply_ops") {
const context = canvasContextRef.current;
if (!context) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
result = context.applyOps(input.ops || []);
void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
} else {
const snapshot = canvasContextRef.current?.snapshot;
if (!snapshot) throw new Error("当前不在画布页,请先用 site_navigate 打开画布");
result = snapshot;
}
await postToolResult(endpoint, token, clientIdRef.current, { requestId: payload.requestId, result });
if (payload.name === "canvas_apply_ops") void postState(endpoint, token, clientIdRef.current, result as CanvasAgentSnapshot);
setAgentState({ activity: "工具完成", waiting: true });
addEventLog(`${toolName(payload.name)}完成`, result, result);
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
addMessage({ role: "tool", title: `${toolName(payload.name)}完成`, text: payload.name === "canvas_apply_ops" ? summarizeCanvasAgentOps(input.ops || []) || "画布操作" : payload.name === "site_navigate" ? `已跳转到 ${input.path || "/"}` : "已完成", detail: { requestId: payload.requestId, name: payload.name, input, result } });
} catch (error) {
const message = error instanceof Error ? error.message : "画布操作失败";
setAgentState({ activity: "工具失败", waiting: false });
@@ -280,44 +321,57 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
await runToolCall(endpoint, token, tool);
};
const undoLastTool = () => {
const restored = onUndoOps();
if (!restored) return;
setAgentState({ activity: "已撤销" });
addMessage({ role: "tool", title: "已撤销", text: "上一次工具操作", detail: restored });
if (connected) void postState(endpoint, token, clientIdRef.current, restored);
};
const toggleAgentConnection = () => {
const toggleAgentConnection = async ({ silent = false }: { silent?: boolean } = {}) => {
if (enabled) {
clearAgentSession({ enabled: false, connected: false, activity: "离线", connectError: "" });
return;
}
if (!endpoint) {
const urlToken = searchParams.get("agentToken") || "";
const urlEndpoint = searchParams.get("agentUrl") || "";
const discovered = urlToken ? null : await discoverAgentConfig(endpoint || DEFAULT_AGENT_URL);
const nextEndpoint = (urlEndpoint || discovered?.url || endpoint || DEFAULT_AGENT_URL).trim().replace(/\/$/, "");
const nextToken = (urlToken || token.trim() || discovered?.token || "").trim();
if (!nextEndpoint) {
const text = "请填写本地 Agent 地址";
setAgentState({ connectError: text });
message.warning(text);
if (!silent) {
setAgentState({ connectError: text });
if (!headless) message.warning(text);
}
return;
}
if (!token.trim()) {
const text = "请填写 Agent token";
setAgentState({ connectError: text });
message.warning(text);
if (!nextToken) {
const text = "没有发现本地 Agent,请先在 Codex 使用插件或手动启动 Canvas Agent";
if (!silent) {
setAgentState({ connectError: text });
if (!headless) message.warning(text);
}
return;
}
try {
const parsed = new URL(endpoint);
const parsed = new URL(nextEndpoint);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("invalid protocol");
} catch {
const text = "本地 Agent 地址格式不正确";
setAgentState({ connectError: text });
message.warning(text);
if (!silent) {
setAgentState({ connectError: text });
if (!headless) message.warning(text);
}
return;
}
errorLoggedRef.current = false;
setAgentState({ url: endpoint, token: token.trim(), enabled: true, connected: false, activity: "连接中", connectError: "", activeTab: "setup" });
setAgentState({ url: nextEndpoint, token: nextToken, enabled: true, connected: false, silentConnect: silent, activity: "连接中", connectError: "", activeTab: "setup" });
};
useEffect(() => {
if (urlAgentAutoConnect && confirmTools) setAgentState({ confirmTools: false });
}, [confirmTools, setAgentState, urlAgentAutoConnect]);
useEffect(() => {
if (!autoConnect || autoConnectRef.current || enabled || connected) return;
autoConnectRef.current = true;
void toggleAgentConnection({ silent: true });
}, [autoConnect, connected, enabled]);
function clearAgentSession(patch: Parameters<typeof setAgentState>[0] = {}) {
setAgentState({
messages: [],
@@ -334,11 +388,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}
const startNewThread = async () => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId) return;
if (!connected) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, "/agent/codex/threads/new", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
setAgentState({ activeThreadId: data.thread?.id || data.workspace?.activeThreadId || "", messages: [], activeTab: "chat", activity: "新对话" });
await loadThreads();
} catch (error) {
@@ -350,11 +403,10 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
};
const resumeThread = async (threadId: string) => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId || !threadId) return;
if (!connected || !threadId) return;
setAgentState({ loadingThreads: true });
try {
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const data = await fetchAgentJson<AgentThreadResponse>(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/resume`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
setAgentState({ activeThreadId: data.thread?.id || threadId, messages: normalizeHistoryMessages(data.messages || []), activeTab: "chat", activity: "已恢复会话" });
await loadThreads();
} catch (error) {
@@ -366,12 +418,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
};
const deleteThread = async (threadId: string) => {
const projectId = snapshotRef.current.projectId;
if (!connected || !projectId || !threadId) return;
if (!connected || !threadId) return;
setAgentState({ loadingThreads: true });
try {
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ canvasId: projectId }) });
const current = useCanvasAgentStore.getState();
await fetchAgentJson(endpoint, token, `/agent/codex/threads/${encodeURIComponent(threadId)}/delete`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({}) });
const current = useAgentStore.getState();
setAgentState({
threads: current.threads.filter((thread) => thread.id !== threadId),
activeThreadId: current.activeThreadId === threadId ? "" : current.activeThreadId,
@@ -398,31 +449,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
});
};
const startResize = (event: ReactPointerEvent<HTMLDivElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = width;
let nextWidth = startWidth;
const onMove = (moveEvent: PointerEvent) => {
nextWidth = clamp(startWidth + startX - moveEvent.clientX, 360, 760);
setAgentState({ width: nextWidth });
};
const onUp = () => {
localStorage.setItem("canvas-agent-panel-width", String(nextWidth));
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
setResizing(false);
};
setResizing(true);
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
};
const addMessage = (item: Omit<AgentChatItem, "id">) => {
const text = normalizeText(item.text);
if (!text && !item.attachments?.length) return;
const next = { ...item, id: `${Date.now()}-${Math.random()}`, text };
const currentMessages = useCanvasAgentStore.getState().messages;
const currentMessages = useAgentStore.getState().messages;
if (next.streamId) {
const index = currentMessages.findIndex((message) => message.streamId === next.streamId);
if (index >= 0) {
@@ -434,7 +465,7 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
if (last?.role === "assistant" && next.role === "assistant" && last.title === next.title) {
const merged = mergeAgentText(last.text, next.text);
if (merged === last.text) return;
setAgentState({ messages: [...useCanvasAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
setAgentState({ messages: [...useAgentStore.getState().messages.slice(0, -1), { ...last, text: merged, meta: next.meta || last.meta }] });
return;
}
pushMessage(next);
@@ -475,8 +506,8 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
}}
right={
<>
<Button size="small" type="text" disabled={!canUndoOps} icon={<RotateCcw className="size-3.5" />} onClick={undoLastTool}>
<Button size="small" type="text" disabled={!connected || loadingThreads} icon={<Plus className="size-3.5" />} onClick={startNewThread}>
</Button>
</>
}
@@ -531,10 +562,11 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
attachments={attachments.map(agentAttachmentToChatAttachment)}
disabled={!connected}
sending={sending || waiting}
placeholder="询问 Codex,或让它操作画布"
placeholder="询问 Codex,或让它操作网站/画布"
theme={theme}
onPromptChange={(prompt) => setAgentState({ prompt })}
onSubmit={sendPrompt}
onStop={stopTurn}
onAddFiles={addAttachments}
onRemoveAttachment={removeAttachment}
left={attachments.length ? <span className="text-[11px]" style={{ color: theme.node.muted }}>{formatBytes(attachmentPayloadBytes(attachments))} / 30MB</span> : null}
@@ -544,28 +576,8 @@ export function CanvasLocalAgentPanel({ snapshot, canUndoOps, collapsed, embedde
</>
);
if (embedded) return content;
return (
<motion.div
className="relative z-[70] flex h-full shrink-0"
initial={{ width: 0, opacity: 0 }}
animate={{ width: collapsed ? 0 : width + 1, opacity: collapsed ? 0 : 1 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ overflow: "clip", pointerEvents: collapsed ? "none" : undefined }}
>
<motion.aside
className="relative flex h-full shrink-0 flex-col border-l"
initial={{ x: 48 }}
animate={{ x: collapsed ? 28 : 0 }}
transition={{ duration: resizing ? 0 : PANEL_MOTION_SECONDS, ease: [0.22, 1, 0.36, 1] }}
style={{ width, background: theme.node.panel, borderColor: theme.node.stroke, color: theme.node.text }}
>
<div className="absolute left-0 top-0 h-full w-1 cursor-col-resize transition hover:bg-current/20" onPointerDown={startResize} />
{content}
</motion.aside>
</motion.div>
);
if (headless) return null;
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 }) {
@@ -618,31 +630,54 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
copyToClipboard(command);
message.success("命令已复制");
};
const codexPluginReminder = (
<div className="rounded-lg border px-3 py-2.5 text-xs leading-5" style={{ borderColor: theme.node.stroke, color: theme.node.muted }}>
<div className="font-medium" style={{ color: theme.node.text }}>Codex </div>
<div className="mt-1"> Codex MCP Codex token `npx -y @basketikun/canvas-agent` Agent MCP</div>
<div className="mt-2 grid gap-1.5">
{[
["移除插件", AGENT_PLUGIN_REMOVE_COMMAND],
["移除手动 MCP", AGENT_MCP_REMOVE_COMMAND],
].map(([label, command]) => (
<div key={command} className="flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<span className="shrink-0 text-[11px]" style={{ color: theme.node.muted }}>{label}</span>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
))}
</div>
</div>
);
return (
<div className="thin-scrollbar min-h-0 flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<div>
<div className="text-base font-semibold leading-6"> Agent</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
127.0.0.1 token Codex
使
</div>
</div>
<div className="space-y-2">
{AGENT_CONNECT_STEPS.map((step) => {
{AGENT_CONNECT_STEPS.map((step, index) => {
const command = "command" in step ? step.command : "";
return (
<div key={step.title} className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
) : null}
</div>
<Fragment key={step.title}>
<div className="rounded-lg px-3 py-2.5">
<div className="text-sm font-medium leading-5">{step.title}</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>{step.text}</div>
{command ? (
<div className="mt-2 flex items-center gap-2 rounded-md border bg-transparent px-2 py-1.5" style={{ borderColor: theme.node.stroke, color: theme.node.text }}>
<code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap text-[11px] leading-5">{command}</code>
<Tooltip title="复制命令">
<Button size="small" type="text" className="!h-6 !w-6 !min-w-6" icon={<Copy className="size-3.5" />} onClick={() => copyCommand(command)} />
</Tooltip>
</div>
) : null}
</div>
{index === 0 ? codexPluginReminder : null}
</Fragment>
);
})}
</div>
@@ -657,7 +692,7 @@ function AgentConnectView({ theme, url, token, enabled, connected, activity, con
</span>
</div>
<div className="mt-1 text-xs leading-5" style={{ color: theme.node.muted }}>
Local URL Connect token
Local URL Connect token
</div>
</div>
<Button className="!h-8 !px-3" type={enabled ? "default" : "primary"} icon={<PlugZap className="size-4" />} onClick={onToggleEnabled}>
@@ -679,7 +714,7 @@ 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" }}>
@@ -752,9 +787,9 @@ function AgentHistoryView({ theme, threads, activeThreadId, workspacePath, loadi
);
}
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot) {
async function postState(endpoint: string, token: string, clientId: string, snapshot: CanvasAgentSnapshot | null) {
try {
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot) });
await fetch(`${endpoint}/canvas/state?token=${encodeURIComponent(token)}&clientId=${encodeURIComponent(clientId)}`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(snapshot ? { ...snapshot, hasCanvas: true } : { hasCanvas: false }) });
} catch {}
}
@@ -883,9 +918,22 @@ function toolName(name: string) {
if (name === "canvas_select_nodes") return "选择节点";
if (name === "canvas_set_viewport") return "调整视口";
if (name === "canvas_run_generation") return "触发生成";
if (name === "site_navigate") return "网站跳转";
if (isSiteTool(name)) return SITE_TOOL_LABELS[name];
return name;
}
function siteToolSummary(name: string, result: unknown) {
const data = result && typeof result === "object" ? (result as Record<string, unknown>) : {};
if (name === "canvas_list_projects") return `${numberField(data, "total")} 个画布`;
if (name === "prompts_search") return `找到 ${numberField(data, "total")} 条提示词`;
if (name === "assets_list") return `${numberField(data, "total")} 个素材`;
if (name === "assets_add") return "已加入我的素材";
if (name === "workbench_image_generate" || name === "workbench_video_generate") return typeof data.note === "string" ? data.note : "已在工作台执行";
if (name === "workbench_image_get_config" || name === "workbench_video_get_config") return "已读取工作台配置";
return "已完成";
}
function isReadTool(name: string) {
return name === "canvas_get_state" || name === "canvas_get_selection" || name === "canvas_export_snapshot";
}
@@ -971,6 +1019,17 @@ async function fetchAgentJson<T>(endpoint: string, token: string, path: string,
return data;
}
async function discoverAgentConfig(endpoint: string) {
try {
const res = await fetch(`${endpoint}/config`);
if (!res.ok) return null;
const data = (await res.json()) as AgentConfigResponse;
return data.ok ? data : null;
} catch {
return null;
}
}
function normalizeHistoryMessages(messages: AgentChatItem[]) {
return messages
.map((item, index) => ({
@@ -987,7 +1046,7 @@ function formatThreadTime(value?: number) {
}
function createId() {
return typeof crypto === "undefined" ? `${Date.now()}-${Math.random()}` : crypto.randomUUID();
return randomId();
}
function clamp(value: number, min: number, max: number) {
@@ -1,10 +1,8 @@
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { nodes: CanvasNodeData[]; viewport: ViewportTransform; viewportSize: { width: number; height: number }; onViewportChange: (viewport: ViewportTransform) => void }) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -115,7 +113,7 @@ export function Minimap({ nodes, viewport, viewportSize, onViewportChange }: { n
>
{nodes.map((node) => {
const pos = toMinimap(node.position.x, node.position.y);
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : theme.node.muted;
const color = node.type === CanvasNodeType.Image ? "#10b981" : node.type === CanvasNodeType.Video ? "#f97316" : node.type === CanvasNodeType.Audio ? "#a855f7" : node.type === CanvasNodeType.Config ? "#60a5fa" : node.type === CanvasNodeType.Group ? "#94a3b8" : theme.node.muted;
return (
<div
key={node.id}
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Modal, Segmented, Slider } from "antd";
import { RotateCcw, WandSparkles } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Modal } from "antd";
import { Check, Lock, LockOpen, X } from "lucide-react";
@@ -3,8 +3,8 @@ import { imageReferenceLabel } from "@/lib/image-reference-prompt";
import { seedanceReferenceLabel } from "@/lib/seedance-video";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
import { getGenerationResourceNodes } from "../utils/canvas-resource-references";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "@/types/canvas";
import { getGenerationResourceNodes } from "@/lib/canvas/canvas-resource-references";
export type NodeGenerationContext = {
prompt: string;
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { App, Modal, Segmented, Tooltip } from "antd";
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
@@ -8,7 +6,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
@@ -102,6 +100,7 @@ export function CanvasNodeHoverToolbar({
if (!node) return null;
const activeNode = node;
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
const top = viewport.y + node.position.y * viewport.k - 14;
const isImage = node.type === CanvasNodeType.Image;
@@ -126,7 +125,7 @@ export function CanvasNodeHoverToolbar({
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
function openImageToolSettings() {
onKeep(node.id);
onKeep(activeNode.id);
setDraftImageToolIds(quickImageToolIds);
setDraftShowImageToolLabels(showImageToolLabels);
setImageToolSettingsOpen(true);
@@ -219,7 +218,6 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
return JSON.stringify(
node,
(key, value) => {
if (key === "title") return undefined;
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
return "[base64 image]";
}
@@ -255,7 +253,8 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
{view === "info" ? (
<div className="thin-scrollbar h-full space-y-3 overflow-auto pr-1">
<InfoRow label="ID" value={node.id} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : "生成配置"} />
<InfoRow label="名称" value={node.title || "未命名节点"} />
<InfoRow label="类型" value={node.type === CanvasNodeType.Text ? "文本" : node.type === CanvasNodeType.Image ? "图片" : node.type === CanvasNodeType.Video ? "视频" : node.type === CanvasNodeType.Audio ? "音频" : node.type === CanvasNodeType.Group ? "组" : "生成配置"} />
<InfoRow label="尺寸" value={`${Math.round(node.width)} x ${Math.round(node.height)}`} />
<InfoRow label="位置" value={`${Math.round(node.position.x)}, ${Math.round(node.position.y)}`} />
<InfoRow label="状态" value={node.metadata?.status || "idle"} />
@@ -282,7 +281,7 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
function ToolbarAction({ title, label, icon, onClick, showLabel, active = false, danger = false }: ToolbarTool & { showLabel: boolean }) {
const hasText = showLabel && Boolean(label);
return (
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ body: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ root: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
<span className={`flex h-9 items-center ${hasText ? "gap-2 px-2.5" : "justify-center px-2"} rounded-lg transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
{icon}
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, Input, Modal, Slider } from "antd";
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
@@ -45,7 +43,7 @@ export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }:
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
const maskCanvas = maskCanvasRef.current;
const context = maskCanvas?.getContext("2d");
if (!context) return;
if (!maskCanvas || !context) return;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = brushSize;
@@ -1,12 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import { ArrowUp, LoaderCircle } from "lucide-react";
import { ArrowUp, LoaderCircle, Square } from "lucide-react";
import { Button } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { defaultConfig, modelMatchesCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
@@ -14,8 +11,8 @@ import { CanvasPromptLibrary } from "./canvas-prompt-library";
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
export type CanvasNodeGenerationMode = CanvasGenerationMode;
@@ -25,11 +22,12 @@ type CanvasNodePromptPanelProps = {
onPromptChange: (nodeId: string, prompt: string) => void;
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
onStop: (nodeId: string) => void;
mentionReferences?: CanvasResourceReference[];
onImageSettingsOpenChange?: (open: boolean) => void;
};
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onStop, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
const globalConfig = useEffectiveConfig();
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const theme = canvasThemes[useThemeStore((state) => state.theme)];
@@ -39,7 +37,6 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
const hasImageContent = node.type === CanvasNodeType.Image && Boolean(node.metadata?.content);
const isEditingExistingContent = hasTextContent || hasImageContent;
const [prompt, setPrompt] = useState(isEditingExistingContent ? "" : node.metadata?.prompt || "");
const credits = requestCreditCost({ channelMode: config.channelMode, model: config.model, count: mode === "image" ? config.count : 1 });
useEffect(() => {
setPrompt(isEditingExistingContent ? "" : node.metadata?.prompt || "");
@@ -107,16 +104,21 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
<Button
type="primary"
className="!h-10 !min-w-16 shrink-0 !rounded-full !px-3"
disabled={isRunning || !prompt.trim()}
onClick={submit}
aria-label="生成"
danger={isRunning}
disabled={!isRunning && !prompt.trim()}
onClick={() => (isRunning ? onStop(node.id) : submit())}
aria-label={isRunning ? "停止生成" : "生成"}
>
<span className="flex items-center gap-1.5">
<span className="inline-flex items-center gap-1 text-xs font-medium tabular-nums">
<CreditSymbol />
{credits.toLocaleString()}
</span>
{isRunning ? <LoaderCircle className="size-4 animate-spin" /> : <ArrowUp className="size-4" />}
{isRunning ? (
<>
<LoaderCircle className="size-4 animate-spin" />
<Square className="size-3.5 fill-current" />
<span className="text-xs font-medium"></span>
</>
) : (
<ArrowUp className="size-4" />
)}
</span>
</Button>
</div>
@@ -130,9 +132,16 @@ function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
const currentModel = node.metadata?.model;
const model = currentModel && modelMatchesCapability(currentModel, mode)
? currentModel
: defaultModel && modelMatchesCapability(defaultModel, mode)
? defaultModel
: fallbackModel;
return {
...globalConfig,
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
model,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
@@ -0,0 +1,186 @@
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { Button, InputNumber, Modal } from "antd";
import { Grid2x2, ListRestart, PanelTop, Rows3, Trash2 } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import type { ImageSplitParams } from "@/lib/canvas/canvas-image-data";
export type CanvasImageSplitParams = ImageSplitParams;
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2, horizontalLines: [0.5], verticalLines: [0.5] };
const maxGridSize = 12;
type ActiveLine = { axis: "horizontal" | "vertical"; index: number } | null;
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
const [params, setParams] = useState(defaultParams);
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
const [active, setActive] = useState<ActiveLine>(null);
const previewRef = useRef<HTMLDivElement>(null);
const horizontalLines = params.horizontalLines || [];
const verticalLines = params.verticalLines || [];
const rows = horizontalLines.length + 1;
const columns = verticalLines.length + 1;
const total = rows * columns;
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / columns)), height: Math.max(1, Math.floor(image.height / rows)) } : null;
useEffect(() => {
if (!open) return;
setParams(defaultParams);
setActive(null);
setImage(null);
}, [dataUrl, open]);
useEffect(() => {
if (!open) return;
void readImageMeta(dataUrl).then(setImage);
}, [dataUrl, open]);
const update = (key: "rows" | "columns", value: string | number | null) => {
const count = clampGrid(value ?? params[key]);
setActive(null);
setParams((current) => ({ ...current, [key]: count, [key === "rows" ? "horizontalLines" : "verticalLines"]: buildGridLines(count) }));
};
const addLine = (axis: "horizontal" | "vertical") => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || []), findLineSpot(current[key] || [])].sort((a, b) => a - b);
return { ...current, [key]: lines, rows: axis === "horizontal" ? lines.length + 1 : current.rows, columns: axis === "vertical" ? lines.length + 1 : current.columns };
});
};
const deleteLine = () => {
if (!active) return;
setParams((current) => {
const key = active.axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = (current[key] || []).filter((_, index) => index !== active.index);
return { ...current, [key]: lines, rows: active.axis === "horizontal" ? lines.length + 1 : current.rows, columns: active.axis === "vertical" ? lines.length + 1 : current.columns };
});
setActive(null);
};
const startDrag = (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => {
event.preventDefault();
setActive({ axis, index });
const box = previewRef.current?.getBoundingClientRect();
if (!box) return;
const move = (moveEvent: PointerEvent) => setLine(axis, index, axis === "horizontal" ? (moveEvent.clientY - box.top) / box.height : (moveEvent.clientX - box.left) / box.width);
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
const setLine = (axis: "horizontal" | "vertical", index: number, value: number) => {
setParams((current) => {
const key = axis === "horizontal" ? "horizontalLines" : "verticalLines";
const lines = [...(current[key] || [])];
lines[index] = clampLine(value, lines[index - 1] ?? 0, lines[index + 1] ?? 1);
return { ...current, [key]: lines };
});
};
const resetLines = () => {
setActive(null);
setParams((current) => ({ ...current, horizontalLines: buildGridLines(current.rows), verticalLines: buildGridLines(current.columns) }));
};
const confirmParams = { ...params, horizontalLines, verticalLines, rows, columns };
return (
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
<div className="space-y-5">
<div>
<h2 className="text-xl font-semibold"></h2>
<p className="mt-1 text-sm opacity-60"> {total} </p>
</div>
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
<div className="rounded-xl border p-4">
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
<div ref={previewRef} className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
<SplitGrid horizontalLines={horizontalLines} verticalLines={verticalLines} active={active} onPointerDown={startDrag} />
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="opacity-60"></span>
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
</div>
</div>
<div className="space-y-5 py-2">
<NumberField label="行数" value={rows} onChange={(value) => update("rows", value)} />
<NumberField label="列数" value={columns} onChange={(value) => update("columns", value)} />
<div className="grid grid-cols-2 gap-2">
<Button icon={<Rows3 className="size-4" />} onClick={() => addLine("horizontal")}>线</Button>
<Button icon={<PanelTop className="size-4 rotate-90" />} onClick={() => addLine("vertical")}>线</Button>
<Button icon={<Trash2 className="size-4" />} disabled={!active} onClick={deleteLine}>线</Button>
<Button icon={<ListRestart className="size-4" />} onClick={resetLines}>线</Button>
</div>
<div className="rounded-xl border px-4 py-3 text-sm">
<div className="flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{total} </span>
</div>
<div className="mt-2 flex items-center justify-between">
<span className="opacity-60"></span>
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
</div>
</div>
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(confirmParams)}>
</Button>
</div>
</div>
</div>
</Modal>
);
}
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) {
return (
<label className="block space-y-2">
<span className="font-medium opacity-75">{label}</span>
<InputNumber className="w-full" min={1} max={maxGridSize} precision={0} value={value} onChange={onChange} />
</label>
);
}
function SplitGrid({ horizontalLines, verticalLines, active, onPointerDown }: { horizontalLines: number[]; verticalLines: number[]; active: ActiveLine; onPointerDown: (axis: "horizontal" | "vertical", index: number, event: ReactPointerEvent) => void }) {
return (
<div className="pointer-events-none absolute inset-0">
{verticalLines.map((line, index) => (
<div key={`column-${index}`} className="pointer-events-auto absolute inset-y-0 -ml-2 w-4 cursor-ew-resize" style={{ left: `${line * 100}%` }} onPointerDown={(event) => onPointerDown("vertical", index, event)}>
<div className={`absolute left-1/2 top-0 h-full border-l shadow-[0_0_0_1px_rgba(0,0,0,.35)] ${active?.axis === "vertical" && active.index === index ? "border-amber-300" : "border-white/90"}`} />
</div>
))}
{horizontalLines.map((line, index) => (
<div key={`row-${index}`} className="pointer-events-auto absolute inset-x-0 -mt-2 h-4 cursor-ns-resize" style={{ top: `${line * 100}%` }} onPointerDown={(event) => onPointerDown("horizontal", index, event)}>
<div className={`absolute left-0 top-1/2 w-full border-t shadow-[0_0_0_1px_rgba(0,0,0,.35)] ${active?.axis === "horizontal" && active.index === index ? "border-amber-300" : "border-white/90"}`} />
</div>
))}
</div>
);
}
function buildGridLines(count: number) {
return Array.from({ length: Math.max(1, count) - 1 }, (_, index) => (index + 1) / count);
}
function findLineSpot(lines: number[]) {
const cuts = [0, ...lines, 1].sort((a, b) => a - b);
let spot = 0.5;
let max = 0;
for (let index = 0; index < cuts.length - 1; index += 1) {
const gap = cuts[index + 1] - cuts[index];
if (gap > max) {
max = gap;
spot = cuts[index] + gap / 2;
}
}
return spot;
}
function clampLine(value: number, min: number, max: number) {
return Math.min(max - 0.01, Math.max(min + 0.01, value));
}
function clampGrid(value: string | number) {
const numberValue = Number(value);
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
}
@@ -1,11 +1,9 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Modal, Segmented } from "antd";
import { ImagePlus } from "lucide-react";
import { readImageMeta } from "@/lib/image-utils";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "../utils/canvas-image-data";
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "@/lib/canvas/canvas-image-data";
export type CanvasImageUpscaleParams = ImageUpscaleParams;
@@ -1,15 +1,13 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
import { ChevronRight, Group, Image as ImageIcon, Music2, RefreshCw, Star, Video } from "lucide-react";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes } from "@/lib/image-utils";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
import { CanvasNodeType, type CanvasNodeData, type Position } from "@/types/canvas";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
const selectionBlue = "#2f80ff";
@@ -30,6 +28,8 @@ type CanvasNodeProps = {
renderPanel?: (node: CanvasNodeData) => ReactNode;
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
batchCount?: number;
groupChildCount?: number;
isGroupDropTarget?: boolean;
batchExpanded?: boolean;
batchClosing?: boolean;
batchOpening?: boolean;
@@ -41,6 +41,7 @@ type CanvasNodeProps = {
onConnectStart: (event: React.MouseEvent, nodeId: string, handleType: "source" | "target") => void;
onResize: (nodeId: string, width: number, height: number, position?: Position) => void;
onContentChange: (nodeId: string, content: string) => void;
onTitleChange: (nodeId: string, title: string) => void;
onToggleBatch?: (nodeId: string) => void;
onSetBatchPrimary?: (node: CanvasNodeData) => void;
onRetry?: (node: CanvasNodeData) => void;
@@ -67,6 +68,7 @@ type NodeContentRendererProps = {
onGenerateImage?: (node: CanvasNodeData) => void;
onToggleBatch?: () => void;
onSetBatchPrimary?: () => void;
groupChildCount: number;
};
export const CanvasNode = React.memo(function CanvasNode({
@@ -85,6 +87,8 @@ export const CanvasNode = React.memo(function CanvasNode({
renderPanel,
renderNodeContent,
batchCount = 0,
groupChildCount = 0,
isGroupDropTarget = false,
batchExpanded = false,
batchClosing = false,
batchOpening = false,
@@ -96,6 +100,7 @@ export const CanvasNode = React.memo(function CanvasNode({
onConnectStart,
onResize,
onContentChange,
onTitleChange,
onToggleBatch,
onSetBatchPrimary,
onRetry,
@@ -106,14 +111,18 @@ export const CanvasNode = React.memo(function CanvasNode({
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const [isEditingContent, setIsEditingContent] = useState(false);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState(data.title || "");
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
const isGroup = data.type === CanvasNodeType.Group;
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
const isBatchChild = data.type === CanvasNodeType.Image && Boolean(data.metadata?.batchRootId);
const isActive = isConnectionTarget || isSelected || isFocusRelated;
const imageBorderColor = isActive ? selectionBlue : isRelated && !isBatchChild ? theme.node.muted : "transparent";
const textareaRef = useRef<HTMLTextAreaElement>(null);
const titleInputRef = useRef<HTMLInputElement>(null);
const resizeRef = useRef({
isResizing: false,
corner: "bottom-right" as ResizeCorner,
@@ -127,6 +136,34 @@ export const CanvasNode = React.memo(function CanvasNode({
ratio: 1,
});
useEffect(() => {
setTitleDraft(data.title || "");
}, [data.title]);
useEffect(() => {
if (!isEditingTitle) return;
titleInputRef.current?.focus();
titleInputRef.current?.select();
}, [isEditingTitle]);
const finishTitleEditing = useCallback(() => {
const title = titleDraft.trim() || data.title || "未命名节点";
setTitleDraft(title);
setIsEditingTitle(false);
if (title !== data.title) onTitleChange(data.id, title);
}, [data.id, data.title, onTitleChange, titleDraft]);
useEffect(() => {
if (!isEditingTitle) return;
const handleOutsidePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && titleInputRef.current?.contains(target)) return;
finishTitleEditing();
};
window.addEventListener("pointerdown", handleOutsidePointerDown, true);
return () => window.removeEventListener("pointerdown", handleOutsidePointerDown, true);
}, [finishTitleEditing, isEditingTitle]);
useEffect(() => {
const textarea = textareaRef.current;
if (!textarea) return;
@@ -239,7 +276,7 @@ export const CanvasNode = React.memo(function CanvasNode({
return (
<div
data-node-id={data.id}
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isSelected ? "z-50" : "z-10"}`}
className={`node-element absolute flex select-none flex-col transition-shadow duration-200 ${isGroup ? "z-[5]" : isSelected ? "z-50" : "z-10"}`}
style={{
transform: `translate(${data.position.x}px, ${data.position.y}px)`,
width: data.width,
@@ -257,12 +294,47 @@ export const CanvasNode = React.memo(function CanvasNode({
}}
onContextMenu={(event) => onContextMenu(event, data.id)}
>
<div className="absolute left-3 top-[-28px] z-[65] max-w-[calc(100%-24px)]" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
{isEditingTitle ? (
<input
ref={titleInputRef}
value={titleDraft}
maxLength={64}
className="h-6 max-w-full border-0 border-b border-dashed bg-transparent px-0 text-left text-xs font-medium outline-none"
style={{ borderColor: theme.node.muted, color: theme.node.text }}
onChange={(event) => setTitleDraft(event.target.value)}
onBlur={finishTitleEditing}
onKeyDown={(event) => {
if (event.key === "Enter") finishTitleEditing();
if (event.key === "Escape") {
setTitleDraft(data.title || "");
setIsEditingTitle(false);
}
}}
/>
) : (
<button
type="button"
className="block max-w-full truncate border-b border-dashed border-transparent px-0 py-0.5 text-left text-xs font-medium opacity-75 transition hover:border-current hover:opacity-100"
style={{ color: theme.node.text }}
title="双击修改节点名称"
onDoubleClick={(event) => {
event.stopPropagation();
setIsEditingTitle(true);
}}
>
{data.title || "未命名节点"}
</button>
)}
</div>
<div
className="relative h-full w-full overflow-visible rounded-3xl border-2"
style={{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
boxShadow: isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
background: isGroup ? `${theme.toolbar.panel}66` : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
borderColor: isGroup ? (isGroupDropTarget || isActive ? selectionBlue : theme.node.stroke) : hasImageContent ? imageBorderColor : isActive ? selectionBlue : isRelated ? theme.node.muted : theme.node.stroke,
borderStyle: isGroup ? "dashed" : "solid",
boxShadow: isGroupDropTarget ? `0 0 0 2px ${selectionBlue}66, inset 0 0 0 999px ${selectionBlue}10` : isActive ? `0 0 0 1px ${selectionBlue}55` : isRelated && !isBatchChild ? `0 0 0 1px ${theme.node.muted}55, 0 18px 48px rgba(0,0,0,.14)` : undefined,
}}
onMouseDown={(event) => onMouseDown(event, data.id)}
onDoubleClick={(event) => {
@@ -285,7 +357,7 @@ export const CanvasNode = React.memo(function CanvasNode({
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
background: isGroup ? "transparent" : hasImageContent || hasVideoContent ? "transparent" : theme.node.fill,
"--batch-from-x": `${batchMotion?.x || 0}px`,
"--batch-from-y": `${batchMotion?.y || 0}px`,
"--batch-from-rotate": `${6 + (batchMotion?.index || 0) * 4}deg`,
@@ -312,13 +384,14 @@ export const CanvasNode = React.memo(function CanvasNode({
onGenerateImage={onGenerateImage}
onToggleBatch={() => onToggleBatch?.(data.id)}
onSetBatchPrimary={() => onSetBatchPrimary?.(data)}
groupChildCount={groupChildCount}
/>
</div>
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
{!isGroup && !hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
@@ -326,10 +399,10 @@ export const CanvasNode = React.memo(function CanvasNode({
<ResizeHandle corner="bottom-right" onMouseDown={handleResizeMouseDown} />
</div>
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
{!isGroup ? <ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
{!isGroup ? <ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
{showPanel && !isGroup && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
</div>
);
});
@@ -350,8 +423,26 @@ const nodeContentRenderers = {
[CanvasNodeType.Config]: EmptyImageContent,
[CanvasNodeType.Video]: VideoNodeContent,
[CanvasNodeType.Audio]: AudioNodeContent,
[CanvasNodeType.Group]: GroupNodeContent,
} satisfies Record<CanvasNodeType, (props: NodeContentRendererProps) => ReactNode>;
function GroupNodeContent({ node, theme, groupChildCount }: NodeContentRendererProps) {
return (
<div className="pointer-events-none flex h-full w-full flex-col p-4">
<div className="flex items-center gap-2 text-sm font-semibold" style={{ color: theme.node.text }}>
<span className="grid size-8 place-items-center rounded-xl" style={{ background: theme.toolbar.activeBg, color: theme.node.muted }}>
<Group className="size-4" />
</span>
<span></span>
<span className="ml-auto rounded-full px-2 py-1 text-[11px] font-medium" style={{ background: theme.node.fill, color: theme.node.muted }}>
{groupChildCount}
</span>
</div>
<div className="mt-3 flex-1 rounded-2xl border border-dashed" style={{ borderColor: theme.node.stroke, background: `${theme.node.fill}55` }} />
</div>
);
}
function LoadingContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-3" style={{ color: theme.node.activeStroke }}>
@@ -523,7 +614,7 @@ function AudioNodeContent({ node, theme }: NodeContentRendererProps) {
<div className="flex h-full w-full flex-col justify-center gap-3 px-4" style={{ background: theme.node.fill, color: theme.node.text }}>
<div className="flex min-w-0 items-center gap-2 text-sm opacity-70">
<Music2 className="size-4 shrink-0" />
<span className="truncate">{node.title || "音频"}</span>
<span className="truncate"></span>
</div>
<audio src={node.metadata.content} controls className="w-full" data-canvas-no-zoom />
</div>
@@ -1,15 +1,14 @@
"use client";
import { Check, Download, Pencil, Trash2, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
import { exportCanvasProjects } from "../utils/canvas-export";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
import { exportCanvasProjects } from "@/lib/canvas/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const editingId = useCanvasUiStore((state) => state.editingProjectId);
@@ -21,7 +20,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const editing = editingId === project.id;
const selected = selectedIds.includes(project.id);
const open = () => router.push(`/canvas/${project.id}`);
const open = () => navigate(`/canvas/${project.id}${searchParams.toString() ? `?${searchParams.toString()}` : ""}`);
const saveTitle = () => {
renameProject(project.id, editingTitle);
stopEditing();
@@ -1,5 +1,3 @@
"use client";
import { useState } from "react";
import { Button, Tooltip } from "antd";
import { BookOpen } from "lucide-react";
@@ -1,13 +1,12 @@
"use client";
import { forwardRef, useMemo, useRef, useState } from "react";
import type { CSSProperties, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
import { createPortal } from "react-dom";
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 "../utils/canvas-resource-references";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
type MentionState = {
start: number;
@@ -133,6 +132,10 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
props.onPointerUp?.(event);
}}
onKeyDown={(event) => {
if (isImeComposing(event)) {
onKeyDown?.(event);
return;
}
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -155,7 +158,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
return;
}
}
if (event.key === "Enter" && onSubmit && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
if (isPlainEnterKey(event) && onSubmit) {
event.preventDefault();
onSubmit();
return;
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Select } from "antd";
@@ -1,7 +1,7 @@
import type { CSSProperties, MouseEvent as ReactMouseEvent, ReactNode, RefObject } from "react";
import { useRef, useState } from "react";
import { Button, Segmented, Switch } from "antd";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { CircleDot, Eraser, FolderOpen, Grid2x2, Group, Hand, Image as ImageIcon, Info, Moon, Music2, Palette, Redo2, Settings2, Square, Sun, Trash2, Type, Undo2, Upload, Video } from "lucide-react";
import { canvasThemes, type CanvasBackgroundMode, type CanvasColorTheme, type CanvasTheme } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
@@ -18,6 +18,7 @@ export function CanvasToolbar({
onAddAudio,
onAddText,
onAddConfig,
onAddGroup,
onUndo,
onRedo,
onUpload,
@@ -38,6 +39,7 @@ export function CanvasToolbar({
onAddAudio: () => void;
onAddText: () => void;
onAddConfig: () => void;
onAddGroup: () => void;
onUndo: () => void;
onRedo: () => void;
onUpload: () => void;
@@ -90,6 +92,9 @@ export function CanvasToolbar({
<ToolbarButton id="tool-config" label="生成配置" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddConfig}>
<Settings2 className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-group" label="组" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onAddGroup}>
<Group className="size-4.5" />
</ToolbarButton>
<ToolbarButton id="tool-upload" label="上传素材" hovered={hovered} hoverStyle={hoverStyle} wrapRef={wrapRef} onTipX={setTipX} onHover={setHovered} onClick={onUpload}>
<Upload className="size-4.5" />
</ToolbarButton>
@@ -281,6 +286,7 @@ function toolLabel(id: string) {
if (id === "tool-video") return "视频";
if (id === "tool-audio") return "音频";
if (id === "tool-config") return "生成配置";
if (id === "tool-group") return "组";
if (id === "tool-upload") return "上传素材";
if (id === "tool-assets") return "我的素材";
if (id === "tool-style") return "画布外观";
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
@@ -1,10 +1,8 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import type { ViewportTransform } from "../types";
import type { ViewportTransform } from "@/types/canvas";
type InfiniteCanvasProps = {
containerRef: React.RefObject<HTMLDivElement | null>;
@@ -13,12 +11,13 @@ type InfiniteCanvasProps = {
onViewportChange: (viewport: ViewportTransform) => void;
onCanvasMouseDown?: (event: React.PointerEvent<HTMLDivElement>) => void;
onCanvasDeselect?: () => void;
onCanvasDoubleClick?: (event: React.MouseEvent<HTMLDivElement>) => void;
onContextMenu?: (event: React.MouseEvent) => void;
onDrop?: (event: React.DragEvent<HTMLDivElement>) => void;
children: React.ReactNode;
};
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onCanvasMouseDown, onCanvasDeselect, onCanvasDoubleClick, onContextMenu, onDrop, children }: InfiniteCanvasProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const panState = useRef({
isPanning: false,
@@ -118,6 +117,12 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
}
};
const handleDoubleClick = (event: React.MouseEvent<HTMLDivElement>) => {
const target = event.target instanceof Element ? event.target : null;
if (target?.closest("[data-canvas-no-zoom],[data-node-id],[data-connection-id]")) return;
onCanvasDoubleClick?.(event);
};
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
if (!panState.current.isPanning) return;
@@ -173,6 +178,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
className="relative h-full w-full cursor-grab select-none overflow-hidden"
style={{ background: theme.canvas.background }}
onPointerDown={handlePointerDown}
onDoubleClick={handleDoubleClick}
onWheel={handleWheel}
onContextMenu={onContextMenu}
onDragOver={(event) => event.preventDefault()}
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useState } from "react";
import { LoaderCircle } from "lucide-react";
+3 -2
View File
@@ -1,5 +1,3 @@
"use client";
import { type ReactNode, useState } from "react";
import { ConfigProvider, Switch } from "antd";
@@ -30,6 +28,9 @@ const aspectOptions = [
{ value: "auto", label: "auto", width: 0, height: 0, icon: "auto" },
];
export const imageQualityOptions = qualityOptions.map((item) => ({ value: item.value, label: item.label }));
export const imageAspectOptions = aspectOptions.map((item) => ({ value: item.size || item.value, label: item.label }));
type ImageSettingsPanelProps = {
config: AiConfig;
onConfigChange: (key: "quality" | "size" | "count", value: string) => void;
+73 -46
View File
@@ -1,15 +1,13 @@
"use client";
import { App, Button, Form, Input, Modal, Progress, Segmented, Select, Tabs } from "antd";
import { Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useState } from "react";
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
import { useEffect, useState } from "react";
import { ModelPicker } from "@/components/model-picker";
import { fetchChannelModels } from "@/services/api/image";
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
import { createModelChannel, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
type ModelGroup = {
capability: ModelCapability;
@@ -34,6 +32,11 @@ const modelGroups: ModelGroup[] = [
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
];
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
{ label: "OpenAI", value: "openai" },
{ label: "Gemini", value: "gemini" },
];
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
const webdavDomainLabels: Record<AppSyncDomainKey, string> = {
canvas: "画布",
@@ -52,9 +55,9 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
);
}
export function AppConfigModal() {
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
const { message } = App.useApp();
const [activeTab, setActiveTab] = useState("channels");
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
const [loadingChannelId, setLoadingChannelId] = useState("");
const [testingWebdav, setTestingWebdav] = useState(false);
const [syncingWebdav, setSyncingWebdav] = useState(false);
@@ -64,12 +67,12 @@ export function AppConfigModal() {
const webdav = useConfigStore((state) => state.webdav);
const updateConfig = useConfigStore((state) => state.updateConfig);
const updateWebdavConfig = useConfigStore((state) => state.updateWebdavConfig);
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
const webdavReady = Boolean(webdav.url.trim());
useEffect(() => setActiveTab(initialTab), [initialTab]);
const saveConfig = (nextConfig: AiConfig) => {
(Object.keys(nextConfig) as Array<keyof AiConfig>).forEach((key) => updateConfig(key, nextConfig[key]));
@@ -92,6 +95,11 @@ export function AppConfigModal() {
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
};
const updateChannelApiFormat = (channel: ModelChannel, apiFormat: ApiCallFormat) => {
const baseUrl = !channel.baseUrl.trim() || channel.baseUrl.trim() === defaultBaseUrlForApiFormat(channel.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : channel.baseUrl;
updateChannel(channel.id, { apiFormat, baseUrl });
};
const addChannel = () => {
updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]);
};
@@ -198,27 +206,10 @@ export function AppConfigModal() {
};
return (
<Modal
title={
<div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
</div>
}
open={isConfigOpen}
width={980}
centered
onCancel={() => setConfigDialogOpen(false)}
styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 12 } }}
footer={
<Button type="primary" onClick={finishConfig}>
</Button>
}
>
<>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
onChange={(key) => setActiveTab(key as ConfigTabKey)}
items={[
{
key: "channels",
@@ -226,11 +217,17 @@ export function AppConfigModal() {
children: (
<Form layout="vertical" requiredMark={false}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
<div>
<div className="text-sm font-semibold"></div>
<div className="mt-1 text-xs text-stone-500"> Base URLAPI Key </div>
<div className="min-w-0 flex-1">
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-100">
<CircleAlert className="size-3.5 shrink-0" />
<span className="font-semibold"></span>
<span>Tab </span>
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold text-amber-900 dark:text-amber-100" onClick={() => setActiveTab("models")}>
</Button>
</div>
</div>
<div className="flex gap-2">
<div className="flex shrink-0 gap-2">
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
</Button>
@@ -245,7 +242,9 @@ export function AppConfigModal() {
<div className="mb-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
<div className="mt-1 text-xs text-stone-500"> {channel.models.length} </div>
<div className="mt-1 text-xs text-stone-500">
{apiFormatLabel(channel.apiFormat)} · {channel.models.length}
</div>
</div>
<div className="flex shrink-0 gap-2">
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
@@ -258,13 +257,16 @@ export function AppConfigModal() {
<Form.Item label="渠道名称" className="mb-0">
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
</Form.Item>
<Form.Item label="调用格式" className="mb-0">
<Select value={channel.apiFormat} options={apiFormatOptions} onChange={(value: ApiCallFormat) => updateChannelApiFormat(channel, value)} />
</Form.Item>
<Form.Item label="Base URL" className="mb-0">
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
</Form.Item>
<Form.Item label="API Key" className="mb-0">
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
</Form.Item>
<Form.Item label="模型列表" className="mb-0">
<Form.Item label="模型列表" className="mb-0 md:col-span-2">
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
</Form.Item>
</div>
@@ -364,22 +366,11 @@ export function AppConfigModal() {
<Cloud className="size-4" />
WebDAV
</div>
<div className="mt-1 text-xs text-stone-500"> AI API Key CORS Next.js </div>
<div className="mt-1 text-xs text-stone-500"> AI API Key WebDAV </div>
</div>
<div className="text-xs text-stone-500">{webdav.lastSyncedAt ? `上次同步 ${formatWebdavTime(webdav.lastSyncedAt)}` : "尚未同步"}</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Form.Item label="连接方式" className="mb-4 md:col-span-2">
<Segmented
block
value={webdav.proxyMode}
onChange={(value) => updateWebdavConfig("proxyMode", value as typeof webdav.proxyMode)}
options={[
{ label: "前端直连", value: "direct" },
{ label: "Next.js 转发", value: "nextjs" },
]}
/>
</Form.Item>
<Form.Item label="WebDAV 地址" className="mb-4">
<Input value={webdav.url} placeholder="https://nas.example.com/webdav" onChange={(event) => updateWebdavConfig("url", event.target.value)} />
</Form.Item>
@@ -409,6 +400,37 @@ export function AppConfigModal() {
},
]}
/>
{showDoneButton ? (
<div className="mt-4 flex justify-end">
<Button type="primary" onClick={finishConfig}>
</Button>
</div>
) : null}
</>
);
}
export function AppConfigModal() {
const isConfigOpen = useConfigStore((state) => state.isConfigOpen);
const configTab = useConfigStore((state) => state.configTab);
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
return (
<Modal
title={
<div>
<div className="text-lg font-semibold"></div>
<div className="mt-1 text-xs font-normal text-stone-500"></div>
</div>
}
open={isConfigOpen}
width={980}
centered
onCancel={() => setConfigDialogOpen(false)}
styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 12 } }}
footer={null}
>
<AppConfigPanel showDoneButton initialTab={configTab} />
</Modal>
);
}
@@ -425,6 +447,7 @@ function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
models,
baseUrl: channels[0]?.baseUrl || config.baseUrl,
apiKey: channels[0]?.apiKey || config.apiKey,
apiFormat: channels[0]?.apiFormat || config.apiFormat,
imageModels,
videoModels,
textModels,
@@ -455,6 +478,10 @@ function uniqueModels(models: string[]) {
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
}
function apiFormatLabel(apiFormat: ApiCallFormat) {
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
}
function formatWebdavTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
@@ -1,5 +1,3 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { ProConfigProvider } from "@ant-design/pro-components";
+27 -12
View File
@@ -1,30 +1,42 @@
"use client";
import { Menu } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Bot, Menu } from "lucide-react";
import { Button, Tooltip } from "antd";
import { Link, useLocation } from "react-router-dom";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { AppConfigModal } from "@/components/layout/app-config-modal";
import { MobileNavDrawer } from "@/components/layout/mobile-nav-drawer";
import { UserStatusActions } from "@/components/layout/user-status-actions";
import { cn } from "@/lib/utils";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useAgentStore } from "@/stores/use-agent-store";
export function AppTopNav() {
const pathname = usePathname();
const { pathname } = useLocation();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const autoConnectRef = useRef(false);
const agentToken = useAgentStore((state) => state.token);
const agentEnabled = useAgentStore((state) => state.enabled);
const agentConnected = useAgentStore((state) => state.connected);
const connectAgent = useAgentStore((state) => state.connectAgent);
const togglePanel = useAgentStore((state) => state.togglePanel);
const panelOpen = useAgentStore((state) => state.panelOpen);
const hideHeader = /^\/canvas\/[^/]+/.test(pathname);
const slug = pathname.split("/").filter(Boolean)[0];
const activeToolSlug = navigationTools.some((tool) => tool.slug === slug) ? (slug as NavigationToolSlug) : undefined;
useEffect(() => {
if (autoConnectRef.current || agentEnabled || agentConnected || !agentToken.trim()) return;
autoConnectRef.current = true;
connectAgent({ silent: true });
}, [agentConnected, agentEnabled, agentToken, connectAgent]);
return (
<>
{!hideHeader ? (
<header className="sticky top-0 z-20 h-16 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
<header className="sticky top-0 z-20 h-14 shrink-0 border-b border-stone-200 bg-background/90 backdrop-blur-xl dark:border-stone-800">
<div className="mx-auto flex h-full max-w-7xl items-stretch justify-between gap-5 px-6">
<div className="flex min-w-0 items-center">
<Link href="/" className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300">
<Link to="/" className="flex h-full shrink-0 items-center gap-2 text-sm font-semibold leading-none tracking-tight text-stone-950 transition hover:text-stone-600 dark:text-stone-100 dark:hover:text-stone-300">
<span
className="size-5 shrink-0 bg-current"
style={{
@@ -45,16 +57,16 @@ export function AppTopNav() {
<Menu className="size-5" />
</button>
<nav className="hide-scrollbar ml-8 hidden h-16 min-w-0 items-center gap-7 overflow-x-auto md:flex">
<nav className="hide-scrollbar ml-8 hidden h-14 min-w-0 items-center gap-7 overflow-x-auto md:flex">
{navigationTools.map((tool) => {
const Icon = tool.icon;
const active = tool.slug === activeToolSlug;
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
to={`/${tool.slug}`}
className={cn(
"relative flex h-16 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
"relative flex h-14 shrink-0 items-center gap-2 text-sm leading-6 transition after:absolute after:inset-x-0 after:bottom-0 after:h-px",
active
? "font-medium text-stone-950 after:bg-stone-950 dark:text-stone-100 dark:after:bg-stone-100"
: "text-stone-500 after:bg-transparent hover:text-stone-950 dark:text-stone-400 dark:hover:text-stone-100",
@@ -69,6 +81,9 @@ export function AppTopNav() {
</div>
<div className="my-auto flex h-9 min-w-0 items-center justify-end gap-2 justify-self-end whitespace-nowrap">
<Tooltip title={panelOpen ? "收起 Agent" : "打开 Agent"}>
<Button type="text" shape="circle" className="!h-8 !w-8 !min-w-8" icon={<Bot className="size-4" />} onClick={togglePanel} aria-label="打开 Agent" />
</Tooltip>
<UserStatusActions />
</div>
</div>
@@ -1,5 +1,3 @@
"use client";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import { App } from "antd";
@@ -1,5 +1,3 @@
"use client";
import { GithubOutlined } from "@ant-design/icons";
import { cn } from "@/lib/utils";
@@ -1,7 +1,5 @@
"use client";
import { Drawer } from "antd";
import Link from "next/link";
import { Link } from "react-router-dom";
import { navigationTools, type NavigationToolSlug } from "@/constant/navigation-tools";
import { cn } from "@/lib/utils";
@@ -22,7 +20,7 @@ export function MobileNavDrawer({ open, activeToolSlug, onClose }: MobileNavDraw
return (
<Link
key={tool.slug}
href={`/${tool.slug}`}
to={`/${tool.slug}`}
onClick={onClose}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-3 text-base transition",
@@ -1,5 +1,3 @@
"use client";
import type { CSSProperties } from "react";
import { BookOpen, Keyboard, Settings2 } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import type { CSSProperties } from "react";
import { Modal, Tag, Timeline } from "antd";
import { useVersionCheck } from "@/hooks/use-version-check";
-2
View File
@@ -1,5 +1,3 @@
"use client";
import { useEffect, useId, useMemo, useState } from "react";
import { Cpu } from "lucide-react";
@@ -1,5 +1,3 @@
"use client";
import { Copy } from "lucide-react";
import type { ReactNode } from "react";
import { Button, Card, Tag } from "antd";
@@ -1,5 +1,3 @@
"use client";
import { Check, Search } from "lucide-react";
import { type UIEvent, useEffect, useState } from "react";
import { App, Empty, Input, Modal, Spin, Tag } from "antd";

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