mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-26 00:14:30 +08:00
Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a4a4a6772 | |||
| ee8f126da7 | |||
| a4dcc679c9 | |||
| b16263eb43 | |||
| a5824f509e | |||
| 7e0288d461 | |||
| 228ca29c82 | |||
| 301fbce472 | |||
| ebd8ae2de7 | |||
| 864ce4101a | |||
| 25e1d173e8 | |||
| 52a5e9e33a | |||
| 2adbe9e43a | |||
| 7b347729ec | |||
| 387d8be961 | |||
| ec0e8c4e24 | |||
| abe3be58a6 | |||
| 050b36fad1 | |||
| 668c3adb52 | |||
| f2a36e86b2 | |||
| d747527e2a | |||
| 798d06b662 | |||
| 257dab5bac | |||
| 81144ea009 | |||
| bac571ef4e | |||
| 965d416aeb | |||
| 3080d6da10 | |||
| 6113986637 | |||
| ca5faa1f4e | |||
| 0b69dfce46 | |||
| 65a91b22e1 | |||
| 9dd0dc28b2 | |||
| 522153b3e6 | |||
| 568f0f1838 | |||
| cebc3dc5c2 | |||
| 443afab7bd | |||
| e635ec6908 | |||
| ef7a218ba6 | |||
| bd0ad0aebf | |||
| 8cbe00e348 | |||
| 6300f5fb26 | |||
| 73090da95a |
@@ -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 +0,0 @@
|
||||
NEXT_PUBLIC_DOC_URL=https://docs.canvas.best
|
||||
@@ -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
|
||||
@@ -8,3 +8,7 @@ data/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.idea
|
||||
web/dist
|
||||
|
||||
**/dist
|
||||
web/public/plugins
|
||||
@@ -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`。
|
||||
- 文档不要写过期日期;除非用户明确要求记录具体时间。
|
||||
|
||||
## 发版本流程
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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&utm_medium=badge&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 · QQ:1844025705
|
||||
|
||||
## 赞助支持
|
||||
|
||||
|
||||
+37
-1
@@ -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,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",
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
|
||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
|
||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||
};
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ description: 画布本地存储、节点结构、媒体文件与清理机制
|
||||
|
||||
# 画布数据结构
|
||||
|
||||
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界。
|
||||
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式。
|
||||
|
||||
## 当前存储位置
|
||||
|
||||
+2
-2
@@ -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/`
|
||||
@@ -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,不需要手动点击“新建画布”。
|
||||
@@ -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 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||
|
||||
@@ -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 route,AI 接口不经过项目后端代理。
|
||||
- 第三方提示词、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
|
||||
```
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ docker run --rm -p 3000:3000 infinite-canvas
|
||||
## 首次使用建议
|
||||
|
||||
- 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。
|
||||
- 如果需要提示词仓库内容,打开 `/prompts` 会通过 Next.js route 拉取并缓存在内存中。
|
||||
- 如果需要提示词仓库内容,打开 `/prompts` 会由浏览器前端直连拉取并缓存到 IndexedDB。
|
||||
- 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 WebDAV。
|
||||
|
||||
## 说明
|
||||
|
||||
@@ -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 自动尝试重连;需验证已连接、连接中、连接失败、断开和刷新自动重连状态。
|
||||
- 画布 Agent:Codex 旧 thread 在重启后不可用时会自动新建会话并重试,需验证进入画布直接发送消息不会再出现 `thread not loaded` 或 `no rollout found`。
|
||||
- 画布 Agent:切换不同画布时会清空并重新加载当前 canvasId 对应的 Codex 会话,需验证不会继续显示上一个画布的对话。
|
||||
- 画布 Agent:右侧 Agent 面板移除网站 / 本机模式切换,固定使用本机 Agent。
|
||||
- 配置与用户偏好:每个模型渠道新增 OpenAI / Gemini 调用格式选择,默认 OpenAI,切换默认格式时同步更新默认 Base URL;渠道 Tab 增加紧凑醒目的提醒,说明模型是否可选需要到“模型”Tab 选择。
|
||||
- Gemini 调用格式:支持拉取 Gemini 模型列表,并用于文本问答、在线 Agent 工具调用、基础生图和图生图请求。
|
||||
- Gemini 生图:生图工作台和画布生图会把尺寸配置转换为 Gemini `responseFormat.image.aspectRatio` 传入,需验证 1:1、9:16、16:9 及自定义像素尺寸的输出比例。
|
||||
- 音频和视频生成:选择 Gemini 调用格式时先给出不支持提示,仍需使用 OpenAI 兼容渠道。
|
||||
- WebDAV 同步:移除 Next.js 转发代理和连接方式选择,改为浏览器前端直接连接 WebDAV 服务。
|
||||
- 提示词库:移除 Next.js route 抓取和服务端内存缓存,改为浏览器前端直连 GitHub 原始文件并缓存到 IndexedDB。
|
||||
- 提示词库:移除已 404 的 `EvoLinkAI/awesome-gpt-image-2-API-and-Prompts` 来源。
|
||||
- 前端构建:移除 Next.js,改为 Vite + React Router,并将前端目录调整为 `pages` / `components` / `stores` / `lib` / `layouts` / `styles`,需验证首页、图片/视频工作台、素材、提示词库和画布路由跳转。
|
||||
- 前端构建:修复 TypeScript 严格检查下的构建报错。
|
||||
- Vercel 部署:新增 `web` 和 `docs` 两个子项目的 Vercel 配置,需分别验证静态前端和文档站部署。
|
||||
- Docker 部署:运行镜像从 Node serve 改为 nginx 静态托管,需验证前端路由刷新回退。
|
||||
- 环境变量:移除过期的后端 `.env.example` 示例文件。
|
||||
- 生图工作台:修复失败图片点击重试后只保存在页面状态、刷新或跳转后丢失的问题,重试成功结果会写入本地图片存储和生成记录。
|
||||
- 视频生成:任务查询接口直接返回 `url` / `result_url` / `video_url` 时会跳过 `/content` 下载,并优先保存到浏览器本地媒体存储。
|
||||
- 视频生成:任务创建或查询失败时会解析接口返回的 `msg` / `message` / 嵌套 JSON 错误,并在生成结果失败卡片里展示真实原因。
|
||||
- 画布生成配置:修复切换文本/视频/音频模式时模型选择仍显示为生图模型(gpt-image-2)的问题,切换模式后应自动匹配对应能力的默认模型。
|
||||
|
||||
@@ -25,11 +25,10 @@ description: 安全漏洞提交和负责任披露说明
|
||||
- 清晰的复现步骤。
|
||||
- 漏洞影响、攻击场景和触发条件。
|
||||
- 已脱敏的日志、截图或 PoC。
|
||||
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或代理接口。
|
||||
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或接口请求。
|
||||
|
||||
## 范围说明
|
||||
|
||||
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 代理、权限控制和供应链风险。
|
||||
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 同步、权限控制和供应链风险。
|
||||
|
||||
第三方模型服务、托管平台、浏览器插件、用户自行泄露的 API Key、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。
|
||||
|
||||
|
||||
+6
-2
@@ -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)
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"installCommand": "bun install",
|
||||
"buildCommand": "bun run build"
|
||||
}
|
||||
+10
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"infinite-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@basketikun/canvas-agent", "mcp"],
|
||||
"startup_timeout_sec": 20,
|
||||
"tool_timeout_sec": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
Vendored
-6
@@ -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.
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
Generated
+18030
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? `` : ""].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) => ``).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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
-2
@@ -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;
|
||||
};
|
||||
+20
-19
@@ -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 }}>
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
-2
@@ -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";
|
||||
+28
-18
@@ -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,
|
||||
+1
-1
@@ -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,
|
||||
+3
-5
@@ -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>
|
||||
);
|
||||
}
|
||||
+2
-4
@@ -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);
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
+6
-6
@@ -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
-3
@@ -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;
|
||||
+204
-145
@@ -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) {
|
||||
+2
-4
@@ -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}
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Modal, Segmented, Slider } from "antd";
|
||||
import { RotateCcw, WandSparkles } from "lucide-react";
|
||||
-2
@@ -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";
|
||||
+2
-2
@@ -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;
|
||||
+6
-7
@@ -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
-3
@@ -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;
|
||||
+27
-18
@@ -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
-3
@@ -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;
|
||||
|
||||
+106
-15
@@ -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>
|
||||
+7
-8
@@ -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();
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import { BookOpen } from "lucide-react";
|
||||
+7
-4
@@ -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;
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Select } from "antd";
|
||||
|
||||
+7
-1
@@ -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 "画布外观";
|
||||
-2
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
+10
-4
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 URL、API 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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user