mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Compare commits
102 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 49851f2b5f | |||
| c81bb8b651 | |||
| b7d0fe10bc | |||
| d91e67a5ad | |||
| d566d5cc46 | |||
| 8fb8d0b224 | |||
| 5e1fd7a825 | |||
| 062e4569aa | |||
| b5dc9c8a87 | |||
| 75c22c2f9f | |||
| bdca6b0a5c | |||
| d4130bbb79 | |||
| 0e1edd1484 | |||
| 5107be0acf | |||
| 219b3e84db | |||
| a3411cf9d9 | |||
| 430d1bd956 | |||
| cf0561d12a | |||
| e6dcd7a832 | |||
| 187c499ef9 | |||
| cf1ea2646f | |||
| 57db0fe752 | |||
| c5f53454aa | |||
| ca6efdf0c3 | |||
| a2586ffe34 | |||
| 5a27aae99e | |||
| 66961ce10f | |||
| 0c4288b832 | |||
| 093260f7b2 | |||
| 805573b980 | |||
| e6829333c5 | |||
| 2959b5d340 | |||
| 2f9776bee3 | |||
| 4beadc6161 | |||
| 13c508bc34 | |||
| 5f627d5baf | |||
| 3f179a607a | |||
| 2d2c7e3d5e | |||
| 3f623ea70c | |||
| 7ac2ab351d | |||
| b6e75c225e | |||
| 75aafc115f | |||
| 8d3f244524 | |||
| 88510223eb | |||
| 3a4a4a6772 | |||
| ee8f126da7 | |||
| eef4d96787 | |||
| f0db29d8e3 | |||
| 3963c6eebf | |||
| c57f7d61a7 | |||
| 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 | |||
| 973d14f4c3 | |||
| f038406070 | |||
| 1b289f6a77 | |||
| 27bd6f004b | |||
| 7893bd72dd | |||
| f7b030e209 | |||
| bce9c2d897 | |||
| b4515ed85f | |||
| 7f0199da59 | |||
| 8a66524ea7 | |||
| 0d90465339 | |||
| d3923e21e0 |
@@ -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,29 +0,0 @@
|
||||
# 管理员账号,首次启动会自动创建。
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=infinite-canvas
|
||||
|
||||
# JWT 登录密钥和过期时间,正式部署请修改 JWT_SECRET。
|
||||
JWT_SECRET=infinite-canvas
|
||||
JWT_EXPIRE_HOURS=168
|
||||
|
||||
# 后端默认监听 8080,如需本地开发修改端口再取消注释。
|
||||
# Docker 镜像内前端固定监听 3000,避免覆盖前端 PORT。
|
||||
# PORT=8080
|
||||
|
||||
# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。
|
||||
# 线上部署时填写站点根地址,例如:https://your-domain.example.com
|
||||
# PUBLIC_BASE_URL=https://your-domain.example.com
|
||||
|
||||
# 前端开发代理默认使用 http://127.0.0.1:8080,如后端开发端口不同,启动前端时可单独设置 API_BASE_URL。
|
||||
# API_BASE_URL=http://127.0.0.1:8080
|
||||
NEXT_PUBLIC_DOC_URL=https://docs.canvas.best
|
||||
|
||||
# 数据库配置,默认使用本地 SQLite。
|
||||
STORAGE_DRIVER=sqlite
|
||||
# sqlite: DATABASE_DSN=data/infinite-canvas.db
|
||||
# Docker 部署时建议使用绝对路径,避免工作目录变化后写入临时库:DATABASE_DSN=/app/data/infinite-canvas.db
|
||||
# mysql 目标库不存在时会尝试自动创建,账号需有 CREATE 权限。
|
||||
# mysql: DATABASE_DSN=user:password@tcp(127.0.0.1:3306)/infinite_canvas?parseTime=true
|
||||
# postgres 目标库不存在时会尝试自动创建,账号需有 CREATEDB 权限。
|
||||
# postgres: DATABASE_DSN=postgres://user:password@127.0.0.1:5432/infinite_canvas?sslmode=disable
|
||||
DATABASE_DSN=data/infinite-canvas.db
|
||||
@@ -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
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Publish Official Plugins
|
||||
|
||||
# 构建官方插件并把产物(bundle + 清单)强推到孤儿分支 plugins-dist。
|
||||
# 与 github-pages.yml 一致,在打版本 tag 时触发 —— 发版时 web 与官方插件一起发布。
|
||||
# 前端经 jsDelivr 从该分支远程拉取;构建产物不进 main、不进 git。
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: publish-plugins
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install build dependencies
|
||||
working-directory: plugins/canvas/registry
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build official plugins
|
||||
working-directory: plugins/canvas/registry
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to plugins-dist branch
|
||||
working-directory: plugins/canvas/registry/dist
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
git init -q
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -q -b plugins-dist
|
||||
git add -A
|
||||
git commit -q -m "publish official plugins @ ${GITHUB_SHA::7}"
|
||||
git push -f "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" plugins-dist
|
||||
@@ -8,3 +8,10 @@ data/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.idea
|
||||
web/dist
|
||||
|
||||
**/dist
|
||||
web/public/plugins
|
||||
plugins/canvas/*/dist
|
||||
plugins/canvas/*/node_modules
|
||||
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/` 共享目录。
|
||||
@@ -61,6 +51,8 @@
|
||||
- 不要硬编码黑白、stone、slate 等颜色导致浅色/深色主题不一致。
|
||||
- 新增画布按钮、弹窗、浮层时,尽量复用已有工具栏、节点面板、Modal 的视觉风格。
|
||||
- 画布顶部工具栏和状态信息优先采用极简扁平风格:无边框、无阴影、无胶囊背景,融入整体背景,弱化按钮感,仅保留轻微 hover 反馈,保持简洁现代、低视觉重量。
|
||||
- 左侧画布面板等列表里的节点/元素缩略图容器,非图片类型(文本、配置、视频、音频等)不要使用 `theme.node.fill`(`#e7e5df`/`#292524`)这类灰色背景,图标直接无背景展示,尽量不要给多余底色,保持干净。
|
||||
- 画布内的操作按钮(如面板里的「添加」「导出」「选择」等)默认用扁平无底色样式:透明背景、仅 `hover:bg-black/5 dark:hover:bg-white/10` 轻微反馈,靠图标+文字表达,不要用 `theme.toolbar.activeBg`(`#e7e5df`/`#3a3631`)或 `theme.node.fill` 之类的灰色作为按钮填充底色。灰色 `activeBg` 只允许用于「选中态」等需要表达状态的高亮,不要当普通装饰底色。
|
||||
- 图片节点尺寸逻辑要尊重原始比例,除非功能明确要求自由变形。
|
||||
- 批量生成、多图展示、助手面板等画布交互要尽量简洁,不要占用过多画布空间。
|
||||
|
||||
@@ -72,10 +64,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,104 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
+ [新增] 提示词来源新增 Banana Prompt Quicker,并支持添加自定义标准 JSON 来源。
|
||||
+ [新增] 提示词中心新增「我的提示词」,支持本地管理、从公共库保存,并可在画布提示词库中直接使用。
|
||||
+ [新增] Agent 新增统一生成任务状态查询,支持查看画布、生图工作台和视频工作台任务进度。
|
||||
+ [调整] 内置提示词来源改为读取 Image Prompts 统一 JSON 数据,不再由画布端分别解析。
|
||||
+ [调整] 提示词来源配置保留卡片式交互,并展示来源数量、同步状态和上次成功时间。
|
||||
+ [优化] 提示词来源支持独立缓存和更新,更新失败时保留上一次成功内容。
|
||||
+ [优化] Agent 对话区分用户与 AI 消息,并优化会话切换和回到最新消息的交互。
|
||||
+ [优化] 画布生成完成后保留提示词输入,并改善提示词库和资产面板的交互性能。
|
||||
+ [修复] 本地 Agent 完善多标签页请求隔离、结果归属、焦点回退和 Codex 会话状态同步。
|
||||
+ [修复] 本地 Agent 上传的图片附件可正确创建画布图片节点并连接生成流程。
|
||||
+ [修复] 画布提示词库支持跨来源搜索,插入节点时保留提示词标题。
|
||||
|
||||
## v0.9.0 - 2026-07-17
|
||||
|
||||
+ [新增] 左侧面板「资产」Tab 支持上传添加图片/视频资产、卡片悬停移除资产。
|
||||
+ [新增] 左侧画布面板支持拖拽调整宽度、展开/收起(带动画),顶栏菜单左侧新增面板开关按钮。
|
||||
+ [新增] 顶栏菜单新增「导出当前画布」,导出为包含全部资源的压缩包。
|
||||
+ [新增] 左侧画布元素列表支持多选并批量导出选中元素为压缩包。
|
||||
+ [新增] 可选的网站统计分析:支持 Google Analytics 4 与百度统计。
|
||||
+ [调整] 画布节点名称默认不再显示,仅在选中/悬停/编辑时出现,画布更简洁。
|
||||
+ [优化] 左侧面板「画布/资产」切换改为带滑动下划线的动画,移除非图片元素图标的灰色底色。
|
||||
+ [优化] 画布节点提示词面板 `@` 引用图片时,输入框内直接显示真实缩略图。
|
||||
+ [优化] 移除画布节点右上角的「图片1/文本1」资源角标,引用改在对话面板 `@` 直接选取。
|
||||
+ [修复] 连接本地 Codex Agent 后,拖拽画布节点边框缩放等高频编辑导致页面崩溃。
|
||||
|
||||
## v0.8.2 - 2026-07-16
|
||||
|
||||
+ [新增] 图像设置新增「透明背景」开关,开启后生成无背景的透明图像。
|
||||
+ [优化] 画布节点输入区域移除灰色底色与边框、美化样式。
|
||||
+ [修复] 画布节点提示词输入框补上悬停文本光标。
|
||||
|
||||
## v0.8.1 - 2026-07-16
|
||||
|
||||
+ [新增] 插件 SDK 扩展:AI 生成能力、面板控制能力。
|
||||
+ [优化] 3D 全景(1.1.0)支持上传与 AI 生成并升级查看器;
|
||||
+ [优化] HTML 节点(1.2.0)迁移到统一交互开关;
|
||||
+ [优化] 便利贴(1.1.0)可拖动移动、自选颜色、移除资源角标与衍生功能;
|
||||
+ [优化] SVG 节点(1.1.0)透明背景融入画布、可拖动、去除默认值;
|
||||
+ [优化] 画布节点渲染性能:memo 化节点回调,交互时不再全量重渲染;
|
||||
+ [修复] 修复 Markdown 节点在点击/移动视角时重复渲染导致图片反复请求的问题;
|
||||
+ [修复] 修复插件版本号显示不更新,插件面板新增可升级绿点提醒。
|
||||
|
||||
## v0.8.0 - 2026-07-15
|
||||
|
||||
+ [新增] 画布节点插件系统:支持通过 URL 动态安装/启用/更新/卸载远程节点插件。
|
||||
+ [新增] 插件开发 SDK,可用 TypeScript 开发画布节点插件。
|
||||
+ [新增] 新增 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
+ [新增] 官方插件注册表:节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
+ [新增] 在画布右上角工具栏新增「节点插件」入口。
|
||||
+ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。
|
||||
|
||||
## v0.7.1 - 2026-07-15
|
||||
|
||||
+ [修复] 修复页面白屏报错的问题。
|
||||
|
||||
## v0.7.0 - 2026-07-14
|
||||
|
||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
|
||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||
+ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。
|
||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||
+ [优化] 本地 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模式。
|
||||
+ [新增] 支持Vercel一键部署。
|
||||
+ [调整] 移除后端,项目定位为个人画布工具。
|
||||
|
||||
## v0.3.0 - 2026-06-15
|
||||
|
||||
+ [新增] 新增canvas-agent通过codex操作画布。
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# Contributor License Agreement
|
||||
|
||||
This Contributor License Agreement ("Agreement") applies to any code,
|
||||
documentation, design asset, issue text, pull request, patch, or other
|
||||
material ("Contribution") that you submit to the infinite-canvas project
|
||||
("Project").
|
||||
|
||||
By submitting a Contribution, you agree to the terms below.
|
||||
|
||||
## 1. Copyright License
|
||||
|
||||
You keep ownership of your Contributions.
|
||||
|
||||
You grant the Project maintainers, including the repository owner, a
|
||||
perpetual, worldwide, non-exclusive, royalty-free, irrevocable license to use,
|
||||
reproduce, modify, prepare derivative works of, publicly display, publicly
|
||||
perform, sublicense, distribute, commercialize, and otherwise exploit your
|
||||
Contributions as part of the Project and related distributions.
|
||||
|
||||
This license allows the maintainers to publish your Contributions under the
|
||||
Project's open-source license, currently GNU Affero General Public License
|
||||
v3.0, and to use them in commercial, proprietary, privately licensed,
|
||||
source-available, or other versions, services, support offerings, or
|
||||
derivatives related to the Project.
|
||||
|
||||
## 2. Relicensing
|
||||
|
||||
You grant the Project maintainers, including the repository owner, the
|
||||
perpetual, worldwide, irrevocable right to relicense the Project, in whole or
|
||||
in part, under any license terms, whether existing now or created in the
|
||||
future.
|
||||
|
||||
You agree that your Contributions may be incorporated into and distributed as
|
||||
part of versions of the Project released under open-source, source-available,
|
||||
proprietary, commercial, private, dual-license, or other licensing models,
|
||||
without requiring additional permission, notice, approval, or compensation.
|
||||
|
||||
This right includes distribution of the Project as software, hosted services,
|
||||
Software-as-a-Service (SaaS), managed services, commercial offerings, OEM
|
||||
products, embedded products, and any other form of distribution or use.
|
||||
|
||||
## 3. Patent License
|
||||
|
||||
If your Contribution is covered by patents that you own or control, you grant
|
||||
the Project maintainers and recipients of the Project a perpetual, worldwide,
|
||||
non-exclusive, royalty-free, irrevocable patent license to make, use, sell,
|
||||
offer for sale, import, and otherwise run, modify, and distribute your
|
||||
Contribution as part of the Project.
|
||||
|
||||
## 4. Right to Contribute
|
||||
|
||||
You represent that:
|
||||
|
||||
* You have the legal right to submit the Contribution.
|
||||
* The Contribution is your original work, or you have permission to submit it
|
||||
under this Agreement.
|
||||
* Any third-party material included in the Contribution is clearly identified
|
||||
and compatible with the Project's license and this Agreement.
|
||||
* If you submit a Contribution on behalf of an employer or organization, you
|
||||
are authorized to do so.
|
||||
|
||||
## 5. No Obligation
|
||||
|
||||
The maintainers are not required to accept, review, merge, publish, or keep any
|
||||
Contribution. The maintainers may modify, reject, revert, or remove
|
||||
Contributions at their sole discretion.
|
||||
|
||||
## 6. No Warranty
|
||||
|
||||
You provide your Contributions "as is", without warranties or conditions of any
|
||||
kind, express or implied, including warranties of merchantability, fitness for
|
||||
a particular purpose, title, and non-infringement.
|
||||
|
||||
## 7. How This Agreement Is Accepted
|
||||
|
||||
Submitting a pull request, patch, commit, issue attachment, design asset,
|
||||
documentation update, or any other Contribution to this repository constitutes
|
||||
acceptance of this Agreement.
|
||||
|
||||
Maintainers may request explicit confirmation by asking contributors to comment:
|
||||
|
||||
```text
|
||||
I have read and agree to CLA.md.
|
||||
```
|
||||
|
||||
or by requiring acceptance through an automated CLA workflow.
|
||||
|
||||
## 8. Contact
|
||||
|
||||
For questions about this Agreement, contact the maintainers through the
|
||||
repository or email `1844025705@qq.com`.
|
||||
+8
-34
@@ -1,46 +1,20 @@
|
||||
# 构建 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
|
||||
|
||||
# 构建 Go 后端入口。
|
||||
FROM golang:1.25-alpine AS api-build
|
||||
# 运行镜像:只启动静态前端,AI 请求由浏览器前台直连用户自己的接口。
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
WORKDIR /app
|
||||
COPY go.mod go.sum ./
|
||||
COPY config ./config
|
||||
COPY handler ./handler
|
||||
COPY middleware ./middleware
|
||||
COPY model ./model
|
||||
COPY repository ./repository
|
||||
COPY router ./router
|
||||
COPY service ./service
|
||||
COPY main.go ./
|
||||
RUN go build -o /server .
|
||||
|
||||
# 运行镜像:Next.js 对外监听 3000,Go 只在容器内部监听 8080。
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY VERSION /app/VERSION
|
||||
COPY CHANGELOG.md /app/CHANGELOG.md
|
||||
COPY --from=api-build /server /app/server
|
||||
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
|
||||
ENV PROMPT_DATA_DIR=/app/data/prompts
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/data/prompts
|
||||
COPY --from=web-build /app/web/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY web/docker-entrypoint.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
EXPOSE 3000
|
||||
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
|
||||
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && PORT=3000 node server.js"]
|
||||
|
||||
@@ -8,71 +8,69 @@
|
||||
<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://www.docker.com/"><img src="https://img.shields.io/badge/Docker-ready-2496ed?style=flat-square&logo=docker&logoColor=white" alt="Docker 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://go.dev/"><img src="https://img.shields.io/badge/Go-1.25-00add8?style=flat-square&logo=go&logoColor=white" alt="Go"></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 请保留原作者信息和前端页面标识。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑、文本问答和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布。
|
||||
- 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
||||
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
||||
- 插件系统:支持通过 URL 动态安装 / 启用 / 更新 / 卸载远程节点插件,并提供 TypeScript SDK 自行开发画布节点插件。
|
||||
- 自定义接口调用:可自定义生图 / 视频接口的调用方式,灵活适配各类中转站与自建服务。
|
||||
- 提示词库:浏览器前端直连多个 GitHub 开源项目,并缓存到 IndexedDB。
|
||||
|
||||
完整功能说明见 [docs/features.md](docs2/features.md)。
|
||||
完整功能说明见 [功能介绍](docs/content/docs/overview/features.mdx)。
|
||||
|
||||
如果你在为担心没有合适的生图API来发愁,可以查看该免费生图项目:[chatgpt2api](https://github.com/basketikun/chatgpt2api)
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 前端:Next.js、React、TypeScript、Tailwind CSS、Ant Design、Zustand、TanStack Query。
|
||||
- 后端:Go、Gin、GORM。
|
||||
- 部署:Docker。
|
||||
|
||||
## 快速开始
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas)
|
||||
AI API Key、Base URL、画布、素材和生成记录默认保存在浏览器本地。
|
||||
|
||||
### 本地开发
|
||||
|
||||
```bash
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
cp .env.example .env
|
||||
# 修改默认账号密码等信息
|
||||
docker-compose up -d
|
||||
cd web
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
本地源码构建运行:
|
||||
### Docker 运行
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
运行后默认端口3000,可访问 `http://localhost:3000`。
|
||||
|
||||
如需要拉取提示词,可前往:`http://localhost:3000/admin/prompts`
|
||||
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
||||
|
||||
## New API 自动配置
|
||||
|
||||
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
|
||||
|
||||
```text
|
||||
https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
```
|
||||
|
||||
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
|
||||
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
|
||||
如果默认的OpenAI接口调用方式与您的API不同,可自定义生图/视频脚本调用。
|
||||
|
||||
## 效果展示
|
||||
|
||||
@@ -89,36 +87,23 @@ https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
<td width="50%"><img src="https://i.ibb.co/bj30FtS5/5.png" alt="5" border="0"></td>
|
||||
<td width="50%"><img src="https://i.ibb.co/hxRvjw51/image.png" alt="image" border="0"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="50%"><img src="https://i.ibb.co/jkWsF8q1/image.png" alt="image" border="0"></td>
|
||||
<td width="50%"><img src="https://i.ibb.co/XrnfXHx7/image.png" alt="image" border="0"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## 文档
|
||||
## 联系方式
|
||||
|
||||
- [功能介绍](docs2/features.md)
|
||||
- [部署说明](docs2/deployment.md)
|
||||
- [画布节点操作手册](docs2/canvas-node-manual.md)
|
||||
- [画布快捷键](docs2/canvas-shortcuts.md)
|
||||
- [待办事项](docs2/todo.md)
|
||||
- [后端数据库说明](docs2/backend-database.md)
|
||||
- [系统配置数据结构](docs2/system-settings.md)
|
||||
- [接口响应约定](docs2/api-response.md)
|
||||
- [本地 Canvas Agent](canvas-agent/README.md)
|
||||
项目定制二次开发需求 / 生图 API 需求可联系。
|
||||
|
||||
邮箱:1844025705@qq.com · QQ:1844025705
|
||||
|
||||
## 赞助支持
|
||||
|
||||
<div align="center">
|
||||
本项目长期开放广告赞助合作,欢迎品牌 / 产品投放,你的支持是持续更新的动力!
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力!
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://ifdian.net/a/basketikun">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
有广告赞助意向请通过上方联系方式沟通。
|
||||
|
||||
## 社区支持
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
infinite-canvas is in active development. Security fixes are accepted for the
|
||||
`main` branch and the latest tagged release. Older versions may be handled on a
|
||||
best-effort basis.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please do not open a public issue with exploit details, credentials, private
|
||||
API keys, proof-of-concept code, or screenshots that reveal sensitive data.
|
||||
|
||||
Preferred reporting channels:
|
||||
|
||||
1. Use GitHub private vulnerability reporting or a GitHub Security Advisory for
|
||||
this repository, if available.
|
||||
2. If a private GitHub report is not available, email `1844025705@qq.com` with
|
||||
the subject `[infinite-canvas security]`.
|
||||
3. If neither private channel is available, open a public issue that asks for a
|
||||
private contact channel and does not include technical exploit details.
|
||||
|
||||
Please include:
|
||||
|
||||
- Affected version, commit, branch, or deployment mode.
|
||||
- Clear reproduction steps.
|
||||
- Impact and attack scenario.
|
||||
- Any relevant logs, screenshots, or proof of concept, with secrets removed.
|
||||
- Whether the issue affects local-only usage, hosted deployments, browser
|
||||
storage, WebDAV sync, AI provider configuration, or API proxy behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
### Canvas node plugins
|
||||
|
||||
The canvas supports third-party node plugins loaded from a remote URL. By
|
||||
design, an installed plugin's code runs directly inside the web app with full
|
||||
access to the page, including locally stored data such as AI API keys. This is
|
||||
an intentional trade-off for extensibility, and the installer shows a warning
|
||||
before installing. Therefore:
|
||||
|
||||
- Only install plugins from sources you trust.
|
||||
- Reports that a *malicious plugin* can access page data or API keys are **out
|
||||
of scope** — that is the documented behavior of the trust model.
|
||||
- Reports **in scope** include: the app loading/executing plugin code without
|
||||
the install confirmation, a plugin escaping its declared node type to break
|
||||
core app integrity in ways not implied by "runs in the page", or the plugin
|
||||
source cache being writable by an unrelated origin.
|
||||
|
||||
Examples of in-scope reports:
|
||||
|
||||
- Cross-site scripting or token exfiltration in the web app.
|
||||
- Exposure of locally stored API keys or synced canvas data caused by project
|
||||
code.
|
||||
- Unsafe file handling, import/export behavior, or WebDAV proxy behavior.
|
||||
- Authentication, authorization, or access-control flaws in project-managed
|
||||
features.
|
||||
- Supply-chain issues that are exploitable through this repository's shipped
|
||||
code or default configuration.
|
||||
|
||||
Examples that are usually out of scope:
|
||||
|
||||
- Vulnerabilities in third-party AI providers, model APIs, hosting platforms,
|
||||
or browser extensions outside this repository.
|
||||
- Compromise of a user's own API key outside the app.
|
||||
- Denial-of-service reports that require unrealistic traffic volume or physical
|
||||
access to the user's device.
|
||||
- Missing security headers without a demonstrated exploit path.
|
||||
- Social engineering, phishing, spam, or account recovery requests.
|
||||
- Dependency reports without a practical impact on this project.
|
||||
|
||||
## Disclosure
|
||||
|
||||
The maintainers aim to acknowledge valid reports within 7 days and coordinate a
|
||||
fix before public disclosure. Response and fix timelines are best effort for
|
||||
this community project.
|
||||
|
||||
Please allow time for investigation and remediation before publishing details.
|
||||
Credit will be given on request unless you prefer to remain anonymous.
|
||||
|
||||
+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",
|
||||
@@ -13,6 +13,7 @@
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "tsx --test src/canvas-session.test.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"prepack": "npm run build"
|
||||
|
||||
+117
-35
@@ -11,11 +11,12 @@ import type { AgentAttachment, AgentEmit } from "./types.js";
|
||||
type Json = Record<string, unknown>;
|
||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
|
||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexAppStart: Promise<CodexAppClient> | null = null;
|
||||
let codexThreadId = "";
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -30,58 +31,80 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
export function interruptCodexTurn(threadId?: string) {
|
||||
if (!codexApp || (threadId && threadId !== codexThreadId)) return false;
|
||||
return codexApp.interruptCurrentTurn();
|
||||
}
|
||||
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
options.onStart?.();
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const threadId = await ensureCodexThread(codexApp, options);
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
const app = await getCodexApp(options.appEmit || emit);
|
||||
let threadId = await ensureCodexThread(app, options, emit);
|
||||
options.onThread?.(threadId);
|
||||
try {
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
|
||||
options.onThread?.(threadId);
|
||||
await app.startTurn(threadId, prompt, files, options.onTurn);
|
||||
}
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
options.onFinish?.();
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const thread = await codexApp.startThread(cwd);
|
||||
const app = await getCodexApp(emit);
|
||||
const thread = await app.startThread(cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
return thread;
|
||||
}
|
||||
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const thread = await codexApp.resumeThread(threadId, cwd);
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
const thread = await app.resumeThread(threadId, cwd);
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
codexThreadId = String(field(thread, "id") || threadId);
|
||||
return { thread, messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd?: string; all?: boolean; searchTerm?: string; limit?: number }) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.listThreads({
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.listThreads({
|
||||
limit: options.limit || 40,
|
||||
sortKey: "updated_at",
|
||||
sortDirection: "desc",
|
||||
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
||||
...(options.all ? {} : { cwd: options.cwd }),
|
||||
cwd: options.cwd,
|
||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||||
});
|
||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread) : [];
|
||||
const data = Array.isArray(field(result, "data")) ? (field(result, "data") as unknown[]).map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
||||
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
||||
}
|
||||
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.readThread(threadId);
|
||||
const thread = field(result, "thread") || {};
|
||||
export async function readCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
await codexApp.archiveThread(threadId);
|
||||
export async function verifyCodexThreadWorkspace(emit: AgentEmit, threadId: string, cwd: string) {
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
}
|
||||
|
||||
export async function archiveCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
const app = await getCodexApp(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await app.archiveThread(threadId);
|
||||
}
|
||||
|
||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
@@ -91,13 +114,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) {
|
||||
if (codexThreadId !== options.threadId) {
|
||||
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)}` });
|
||||
}
|
||||
return codexThreadId;
|
||||
}
|
||||
if (!codexThreadId) {
|
||||
const thread = await app.startThread(options.cwd);
|
||||
@@ -106,6 +136,10 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions)
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
export function isRecoverableThreadError(error: unknown) {
|
||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||
}
|
||||
|
||||
class CodexAppClient {
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
@@ -155,18 +189,19 @@ class CodexAppClient {
|
||||
return this.request("thread/list", params);
|
||||
}
|
||||
|
||||
readThread(threadId: string) {
|
||||
return this.request("thread/read", { threadId, includeTurns: true });
|
||||
readThread(threadId: string, includeTurns = true) {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
archiveThread(threadId: string) {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
async startTurn(threadId: string, prompt: string, images: string[]) {
|
||||
async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
|
||||
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
||||
const turnId = String(field(field(result, "turn"), "id") || "");
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
onTurn?.(turnId);
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
@@ -176,6 +211,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 });
|
||||
@@ -228,9 +273,9 @@ class CodexAppClient {
|
||||
} else if (turnId) {
|
||||
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
||||
}
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount });
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount, ...codexEventScope(params) });
|
||||
this.deltaCount = 0;
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage });
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +284,7 @@ class CodexAppClient {
|
||||
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
||||
this.deltaCount += 1;
|
||||
this.textByItem.set(id, text);
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } });
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text }, ...codexEventScope(params) });
|
||||
}
|
||||
|
||||
private answerServerRequest(message: Json) {
|
||||
@@ -282,15 +327,51 @@ function codexInput(prompt: string, images: string[]) {
|
||||
}
|
||||
|
||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
||||
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
||||
if (method === "turn/started") return { type: "turn.started" };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "error") return { type: "error", message: field(params, "message") };
|
||||
const scope = codexEventScope(params);
|
||||
if (method === "thread/started") return { type: "thread.started", ...scope };
|
||||
if (method === "turn/started") return { type: "turn.started", ...scope };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null, ...scope };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
|
||||
if (method === "error") return { type: "error", message: field(params, "message"), ...scope };
|
||||
return null;
|
||||
}
|
||||
|
||||
function codexEventScope(params: Json) {
|
||||
const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
|
||||
}
|
||||
|
||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||
const app = await getCodexApp(emit);
|
||||
const result = await app.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
return thread;
|
||||
}
|
||||
|
||||
async function getCodexApp(emit: AgentEmit) {
|
||||
if (codexApp) return codexApp;
|
||||
codexAppStart ||= CodexAppClient.start(emit);
|
||||
try {
|
||||
codexApp = await codexAppStart;
|
||||
return codexApp;
|
||||
} finally {
|
||||
codexAppStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
function assertThreadWorkspace(thread: unknown, cwd?: string) {
|
||||
if (!cwd || threadInWorkspace(thread, cwd)) return;
|
||||
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
||||
}
|
||||
|
||||
function threadInWorkspace(thread: unknown, cwd: string) {
|
||||
const threadCwd = String(field(thread, "cwd") || "");
|
||||
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
||||
}
|
||||
|
||||
function normalizeItem(item: unknown) {
|
||||
const value = item && typeof item === "object" ? { ...(item as Json) } : {};
|
||||
if (value.type === "agentMessage") value.type = "agent_message";
|
||||
@@ -401,6 +482,7 @@ function toolName(name: string) {
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_attachment_nodes") return "添加附件图片";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ServerResponse } from "node:http";
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
|
||||
test("MCP 读取当前激活网页的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
|
||||
session.activateClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
|
||||
session.activateClient("second");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("画布写操作只发送给当前激活网页", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "只写入第二个画布" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "canvas_apply_ops");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
test("当前 turn 的图片附件可在发起标签页画布创建图片节点", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
t.after(() => first.close());
|
||||
const dataUrl = "data:image/png;base64,aW1hZ2U=";
|
||||
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", size: 5, width: 1200, height: 600, dataUrl }]);
|
||||
session.bindClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"], x: 100, y: 200 });
|
||||
const call = first.event("tool_call");
|
||||
const input = field(call, "input") as Record<string, unknown>;
|
||||
const nodes = input.nodes as Array<Record<string, unknown>>;
|
||||
assert.equal(field(call, "name"), "canvas_create_attachment_nodes");
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(nodes[0].attachmentId, "attachment-1");
|
||||
assert.equal(nodes[0].title, "商品.png");
|
||||
assert.deepEqual(nodes[0].position, { x: 100, y: 200 });
|
||||
assert.equal(nodes[0].width, 640);
|
||||
assert.equal(nodes[0].height, 320);
|
||||
assert.equal("dataUrl" in nodes[0], false);
|
||||
assert.equal(session.getTurnAttachment("first", "attachment-1").dataUrl, dataUrl);
|
||||
|
||||
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
const created = (await result) as { nodes: Array<{ id: string; attachmentId: string; title: string }> };
|
||||
assert.equal(created.nodes[0].id, nodes[0].id);
|
||||
assert.equal(created.nodes[0].attachmentId, "attachment-1");
|
||||
session.clearTurnAttachments("first");
|
||||
assert.throws(() => session.getTurnAttachment("first", "attachment-1"), /找不到/);
|
||||
});
|
||||
|
||||
test("图片附件只允许发起 turn 的标签页读取和落入画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.setTurnAttachments("first", [{ id: "attachment-1", name: "商品.png", type: "image/png", dataUrl: "data:image/png;base64,aW1hZ2U=" }]);
|
||||
session.bindClient("second");
|
||||
|
||||
await assert.rejects(session.callTool("canvas_create_attachment_nodes", { attachmentIds: ["attachment-1"] }), /发起标签页/);
|
||||
assert.throws(() => session.getTurnAttachment("second", "attachment-1"), /发起标签页/);
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(second.event("tool_call"), undefined);
|
||||
});
|
||||
|
||||
test("tool result is accepted only from the request client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("first");
|
||||
|
||||
const result = session.callTool("canvas_create_text_node", { text: "first only" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
|
||||
assert.equal(session.resolveResult("second", { requestId, result: { client: "second" } }), false);
|
||||
assert.equal(session.resolveResult("first", { requestId, result: { client: "first" } }), true);
|
||||
assert.deepEqual(await result, { client: "first" });
|
||||
});
|
||||
|
||||
test("生成状态查询由当前激活网页返回", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.activateClient("second");
|
||||
|
||||
const result = session.callTool("generation_get_status", { scope: "all" });
|
||||
const call = second.event("tool_call");
|
||||
assert.equal(first.event("tool_call"), undefined);
|
||||
assert.equal(field(call, "name"), "generation_get_status");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { total: 1, tasks: [{ id: "image-1", status: "running" }] } });
|
||||
assert.deepEqual(await result, { total: 1, tasks: [{ id: "image-1", status: "running" }] });
|
||||
});
|
||||
|
||||
test("活动网页关闭后回退到仍连接的画布", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
});
|
||||
|
||||
test("closing the active client falls back to the most recently focused client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
const third = connect(session, "third");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
third.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.updateState(snapshot("canvas-third"), "third");
|
||||
session.activateClient("third");
|
||||
session.activateClient("second");
|
||||
second.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-third");
|
||||
});
|
||||
|
||||
test("closing a client rejects its pending tool requests", async () => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "pending" });
|
||||
const call = first.event("tool_call");
|
||||
const requestId = String(field(call, "requestId"));
|
||||
first.close();
|
||||
|
||||
const outcome = await Promise.race([
|
||||
result.then(() => "resolved", (error) => error instanceof Error ? error.message : String(error)),
|
||||
new Promise<string>((resolve) => setTimeout(() => resolve("pending"), 20)),
|
||||
]);
|
||||
if (outcome === "pending") session.resolveResult("first", { requestId, result: null });
|
||||
assert.match(outcome, /断开/);
|
||||
});
|
||||
|
||||
test("shared thread events are broadcast with the active thread id", (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
|
||||
session.emitThread("workspace_changed", "thread-2", { activeThreadId: "thread-2" });
|
||||
|
||||
assert.deepEqual(first.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||
assert.deepEqual(second.event("workspace_changed"), { activeThreadId: "thread-2", threadId: "thread-2" });
|
||||
});
|
||||
|
||||
test("new clients receive the current Codex state and later updates", (t) => {
|
||||
const session = new CanvasSession();
|
||||
session.setCodexState({ busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
const client = connect(session, "first");
|
||||
t.after(() => client.close());
|
||||
|
||||
assert.deepEqual(field(client.event("hello"), "codex"), { busy: true, threadId: "thread-2", turnId: "turn-1" });
|
||||
|
||||
session.setCodexState({ busy: false });
|
||||
assert.deepEqual(client.event("codex_state"), { busy: false, threadId: "thread-2", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
test("a bound client remains the tool target while focus changes", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.bindClient("first");
|
||||
session.activateClient("second");
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-first");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "bound" });
|
||||
const call = first.event("tool_call");
|
||||
assert.equal(second.event("tool_call"), undefined);
|
||||
session.resolveResult("first", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
|
||||
session.releaseClient("first");
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
});
|
||||
|
||||
test("closing the bound client falls back to the active client", async (t) => {
|
||||
const session = new CanvasSession();
|
||||
const first = connect(session, "first");
|
||||
const second = connect(session, "second");
|
||||
t.after(() => {
|
||||
first.close();
|
||||
second.close();
|
||||
});
|
||||
session.updateState(snapshot("canvas-first"), "first");
|
||||
session.updateState(snapshot("canvas-second"), "second");
|
||||
session.bindClient("first");
|
||||
session.activateClient("second");
|
||||
first.close();
|
||||
|
||||
assert.equal(field(await session.callTool("canvas_get_state", {}), "projectId"), "canvas-second");
|
||||
const result = session.callTool("canvas_create_text_node", { text: "fallback" });
|
||||
const call = second.event("tool_call");
|
||||
session.resolveResult("second", { requestId: String(field(call, "requestId")), result: { ok: true } });
|
||||
assert.deepEqual(await result, { ok: true });
|
||||
});
|
||||
|
||||
function connect(session: CanvasSession, clientId: string) {
|
||||
const response = new FakeSseResponse();
|
||||
session.openEvents(new URL(`http://127.0.0.1/events?clientId=${clientId}`), response as unknown as ServerResponse);
|
||||
return response;
|
||||
}
|
||||
|
||||
function snapshot(projectId: string) {
|
||||
return { projectId, title: projectId, nodes: [], connections: [], selectedNodeIds: [], viewport: { x: 0, y: 0, k: 1 } };
|
||||
}
|
||||
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>)[key] : undefined;
|
||||
}
|
||||
|
||||
class FakeSseResponse extends EventEmitter {
|
||||
private chunks: string[] = [];
|
||||
|
||||
writeHead() {
|
||||
return this;
|
||||
}
|
||||
|
||||
write(chunk: string) {
|
||||
this.chunks.push(chunk);
|
||||
return true;
|
||||
}
|
||||
|
||||
event(type: string) {
|
||||
const chunk = this.chunks.find((item) => item.startsWith(`event: ${type}\n`));
|
||||
const data = chunk?.split("\n").find((line) => line.startsWith("data: "))?.slice(6);
|
||||
return data ? (JSON.parse(data) as unknown) : undefined;
|
||||
}
|
||||
|
||||
close() {
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -3,51 +3,165 @@ import type { ServerResponse } from "node:http";
|
||||
|
||||
import { type ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
import type { AgentAttachment, CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type PendingRequest = { clientId: string; resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type TurnAttachment = { clientId: string; id: string; name: string; type: string; size: number; width: number; height: number; dataUrl: string };
|
||||
export type CodexState = { busy: boolean; threadId: string; turnId: string };
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
"generation_get_status",
|
||||
]);
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private clientFocusOrder = new Map<string, number>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasState: CanvasSnapshot | null = null;
|
||||
private canvasStates = new Map<string, CanvasSnapshot>();
|
||||
private turnAttachments = new Map<string, TurnAttachment>();
|
||||
private activeClientId = "";
|
||||
private boundClientId = "";
|
||||
private focusSequence = 0;
|
||||
private codexState: CodexState = { busy: false, threadId: "", turnId: "" };
|
||||
|
||||
private get canvasState() {
|
||||
return this.canvasStates.get(this.targetClientId) || null;
|
||||
}
|
||||
|
||||
private get targetClientId() {
|
||||
return this.boundClientId || this.activeClientId;
|
||||
}
|
||||
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size };
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size, codexBusy: this.codexState.busy };
|
||||
}
|
||||
|
||||
get codexBusy() {
|
||||
return this.codexState.busy;
|
||||
}
|
||||
|
||||
setCodexState(patch: Partial<CodexState>) {
|
||||
this.codexState = { ...this.codexState, ...patch };
|
||||
this.emitAll("codex_state", this.codexState);
|
||||
}
|
||||
|
||||
openEvents(url: URL, res: ServerResponse) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
const statusOnly = url.searchParams.get("role") === "status";
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
this.clients.set(clientId, res);
|
||||
sendEvent(res, "hello", { ok: true, clientId });
|
||||
if (!statusOnly) {
|
||||
this.clients.set(clientId, res);
|
||||
if (!this.clientFocusOrder.has(clientId)) this.clientFocusOrder.set(clientId, 0);
|
||||
if (!this.activeClientId) {
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
}
|
||||
sendEvent(res, "hello", { ok: true, clientId, codex: this.codexState });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
if (statusOnly || this.clients.get(clientId) !== res) return;
|
||||
this.clients.delete(clientId);
|
||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
||||
this.clientFocusOrder.delete(clientId);
|
||||
this.canvasStates.delete(clientId);
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
this.pending.forEach((item, requestId) => {
|
||||
if (item.clientId !== clientId) return;
|
||||
this.pending.delete(requestId);
|
||||
item.reject(new Error("请求页面已断开"));
|
||||
});
|
||||
if (this.activeClientId === clientId) this.activeClientId = [...this.clients.keys()].sort((a, b) => (this.clientFocusOrder.get(b) || 0) - (this.clientFocusOrder.get(a) || 0))[0] || "";
|
||||
});
|
||||
}
|
||||
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
|
||||
const targetClientId = clientId || this.activeClientId;
|
||||
if (!targetClientId) return;
|
||||
this.canvasStates.set(targetClientId, { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId: targetClientId } as CanvasSnapshot);
|
||||
}
|
||||
|
||||
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
activateClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.activeClientId = clientId;
|
||||
this.clientFocusOrder.set(clientId, ++this.focusSequence);
|
||||
}
|
||||
|
||||
bindClient(clientId: string) {
|
||||
if (!this.clients.has(clientId)) throw new Error("当前网页未连接");
|
||||
this.boundClientId = clientId;
|
||||
}
|
||||
|
||||
releaseClient(clientId: string) {
|
||||
if (this.boundClientId === clientId) this.boundClientId = "";
|
||||
}
|
||||
|
||||
setTurnAttachments(clientId: string, attachments: AgentAttachment[]) {
|
||||
this.turnAttachments.clear();
|
||||
return attachments.flatMap((item, index) => {
|
||||
if (!item.dataUrl?.startsWith("data:image/")) return [];
|
||||
const id = item.id?.trim() || `attachment-${crypto.randomUUID()}`;
|
||||
const attachment: TurnAttachment = {
|
||||
clientId,
|
||||
id,
|
||||
name: item.name?.trim() || `图片 ${index + 1}`,
|
||||
type: item.type?.startsWith("image/") ? item.type : item.dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png",
|
||||
size: positiveNumber(item.size, 0),
|
||||
width: positiveNumber(item.width, 1024),
|
||||
height: positiveNumber(item.height, 1024),
|
||||
dataUrl: item.dataUrl,
|
||||
};
|
||||
this.turnAttachments.set(id, attachment);
|
||||
return [{ id, name: attachment.name, type: attachment.type, size: attachment.size, width: attachment.width, height: attachment.height }];
|
||||
});
|
||||
}
|
||||
|
||||
clearTurnAttachments(clientId?: string) {
|
||||
this.turnAttachments.forEach((item, id) => {
|
||||
if (!clientId || item.clientId === clientId) this.turnAttachments.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
getTurnAttachment(clientId: string, attachmentId: string) {
|
||||
const attachment = this.turnAttachments.get(attachmentId);
|
||||
if (!attachment) throw new Error(`找不到本轮图片附件:${attachmentId}`);
|
||||
if (attachment.clientId !== clientId) throw new Error("图片附件不属于当前 turn 的发起标签页");
|
||||
return attachment;
|
||||
}
|
||||
|
||||
resolveResult(clientId: string, body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId) return;
|
||||
if (!item || !body.requestId || item.clientId !== clientId) return false;
|
||||
this.pending.delete(body.requestId);
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
return true;
|
||||
}
|
||||
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
emitThread(type: string, threadId: string, payload: Record<string, unknown> = {}) {
|
||||
this.emitAll(type, { ...payload, threadId });
|
||||
}
|
||||
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
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);
|
||||
@@ -55,6 +169,7 @@ export class CanvasSession {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
if (tool === "canvas_create_attachment_nodes") return await this.createAttachmentNodes(input as { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" });
|
||||
if (tool === "canvas_create_node") {
|
||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||
input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] };
|
||||
@@ -149,9 +264,36 @@ export class CanvasSession {
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
|
||||
private async createAttachmentNodes(input: { attachmentIds: string[]; x?: number; y?: number; gap?: number; direction?: "row" | "column" }) {
|
||||
const clientId = this.targetClientId;
|
||||
if (!this.clients.has(clientId)) throw new Error("当前没有已连接画布");
|
||||
const attachments = input.attachmentIds.map((id) => this.getTurnAttachment(clientId, id));
|
||||
const x = Number(input.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(input.y ?? 0);
|
||||
const gap = Number(input.gap ?? 40);
|
||||
const direction = input.direction || "row";
|
||||
let offset = 0;
|
||||
const nodes = attachments.map((attachment) => {
|
||||
const size = fitAttachmentNodeSize(attachment.width, attachment.height);
|
||||
const node = {
|
||||
id: `image-${crypto.randomUUID()}`,
|
||||
attachmentId: attachment.id,
|
||||
title: attachment.name,
|
||||
position: { x: direction === "row" ? x + offset : x, y: direction === "column" ? y + offset : y },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
offset += (direction === "row" ? size.width : size.height) + gap;
|
||||
return node;
|
||||
});
|
||||
await this.requestCanvasTool("canvas_create_attachment_nodes", { nodes });
|
||||
return { nodes: nodes.map(({ id, attachmentId, title }) => ({ id, attachmentId, title })) };
|
||||
}
|
||||
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
|
||||
const clientId = this.targetClientId;
|
||||
const client = this.clients.get(clientId);
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
return await new Promise((resolve, reject) => {
|
||||
@@ -159,7 +301,7 @@ export class CanvasSession {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
this.pending.set(requestId, { clientId, resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -246,3 +388,13 @@ function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefi
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
|
||||
function positiveNumber(value: unknown, fallback: number) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number > 0 ? number : fallback;
|
||||
}
|
||||
|
||||
function fitAttachmentNodeSize(width: number, height: number) {
|
||||
const scale = Math.min(1, 640 / width, 640 / height);
|
||||
return { width: width * scale, height: height * scale };
|
||||
}
|
||||
|
||||
+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。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
||||
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。本轮若有用户上传的图片附件,会同时给出 attachmentId;用户要求把附件放入画布或作为生成参考图时,必须先用 canvas_create_attachment_nodes 创建真实图片节点,再把返回的节点 ID 传给 canvas_create_generation_flow.referenceNodeIds,不要创建空图片占位节点。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
||||
|
||||
export type 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 };
|
||||
|
||||
+119
-31
@@ -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, withAgentPrompt } from "./agents.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
||||
import type { AgentAttachment } from "./types.js";
|
||||
|
||||
export function startHttpServer() {
|
||||
@@ -12,7 +12,16 @@ export function startHttpServer() {
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
|
||||
const emit = (type: string, payload: unknown) => {
|
||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
const threadId = String(data.threadId || data.thread_id || ensureSiteWorkspace(config).activeThreadId || "");
|
||||
threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
|
||||
};
|
||||
const setActiveThread = (activeThreadId: string, payload: Record<string, unknown> = {}) => {
|
||||
const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
|
||||
session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId });
|
||||
return workspace;
|
||||
};
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "30mb" }));
|
||||
@@ -33,59 +42,130 @@ export function startHttpServer() {
|
||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
session.resolveResult(req.body);
|
||||
app.post("/canvas/activate", (req, res) => {
|
||||
session.activateClient(String(req.query.clientId || ""));
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
|
||||
res.status(ok ? 200 : 409).json({ ok });
|
||||
});
|
||||
app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
|
||||
const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
|
||||
const data = attachment.dataUrl.split(",", 2)[1];
|
||||
if (!data) throw new Error("图片附件内容无效");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.type(attachment.type).send(Buffer.from(data, "base64"));
|
||||
}));
|
||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||
app.get("/agent/codex/workspace", (req, res) => {
|
||||
const workspace = 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 result = await listCodexThreads(emit, { cwd: workspace.workspacePath, all: req.query.all === "1", searchTerm: String(req.query.searchTerm || "") });
|
||||
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) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
|
||||
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, ...(await readCodexThread(emit, threadId)) });
|
||||
try {
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
} catch (error) {
|
||||
if (workspace.activeThreadId !== threadId || !isRecoverableThreadError(error)) throw error;
|
||||
res.json({ ok: true, workspace, thread: { id: threadId, preview: "", cwd: workspace.workspacePath }, messages: [] });
|
||||
}
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
|
||||
const nextWorkspace = setActiveThread(threadId);
|
||||
res.json({ ok: true, workspace: nextWorkspace, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId);
|
||||
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "");
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
app.post("/agent/codex/turn", route(async (req, res) => {
|
||||
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
|
||||
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
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 });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const prompt = String(req.body?.prompt || "");
|
||||
if (!prompt.trim()) return res.status(400).json({ ok: false, error: "请输入任务内容" });
|
||||
const clientId = String(req.body?.clientId || "");
|
||||
session.setCodexState({ busy: true, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
try {
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
let turnId = "";
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
setActiveThread(threadId, { emptyThread: true });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
setActiveThread(threadId);
|
||||
}
|
||||
const attachmentRefs = session.setTurnAttachments(clientId, attachments);
|
||||
const chatMessage = {
|
||||
sourceClientId: clientId,
|
||||
message: { id: String(req.body?.messageId || Date.now()), role: "user", text: String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`) },
|
||||
};
|
||||
let chatThreadId = "";
|
||||
const turnEmit = (type: string, payload: unknown) => {
|
||||
const data = payload && typeof payload === "object" && !Array.isArray(payload) ? payload as Record<string, unknown> : { value: payload };
|
||||
session.emitThread(type, threadId, data);
|
||||
};
|
||||
void runCodexTurn(withAgentPrompt(withAttachmentContext(prompt, attachmentRefs)), turnEmit, attachments, {
|
||||
threadId,
|
||||
cwd: workspace.workspacePath,
|
||||
appEmit: emit,
|
||||
onStart: clientId ? () => session.bindClient(clientId) : undefined,
|
||||
onThread: (actualThreadId) => {
|
||||
if (actualThreadId !== threadId) {
|
||||
threadId = actualThreadId;
|
||||
setActiveThread(threadId, { emptyThread: true });
|
||||
}
|
||||
session.setCodexState({ busy: true, threadId, turnId: "" });
|
||||
if (chatThreadId !== threadId) {
|
||||
chatThreadId = threadId;
|
||||
session.emitThread("chat_message", threadId, chatMessage);
|
||||
}
|
||||
},
|
||||
onTurn: (actualTurnId) => {
|
||||
turnId = actualTurnId;
|
||||
session.setCodexState({ busy: true, threadId, turnId });
|
||||
},
|
||||
onFinish: () => {
|
||||
session.clearTurnAttachments(clientId);
|
||||
if (clientId) session.releaseClient(clientId);
|
||||
session.setCodexState({ busy: false, threadId, turnId });
|
||||
},
|
||||
});
|
||||
res.json({ ok: true, threadId });
|
||||
} catch (error) {
|
||||
session.setCodexState({ busy: false, threadId: String(req.body?.threadId || workspace.activeThreadId || ""), turnId: "" });
|
||||
throw error;
|
||||
}
|
||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
||||
res.json({ ok: true, threadId });
|
||||
}));
|
||||
app.post("/agent/codex/interrupt", (req, res) => {
|
||||
const ok = interruptCodexTurn(String(req.body?.threadId || ""));
|
||||
res.json({ ok });
|
||||
});
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
|
||||
res.json({ ok: true });
|
||||
@@ -97,7 +177,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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,3 +215,9 @@ function validToken(req: Request, url: URL, token: string) {
|
||||
const header = req.headers["x-canvas-agent-token"];
|
||||
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
||||
}
|
||||
|
||||
function withAttachmentContext(prompt: string, attachments: Array<{ id: string; name: string }>) {
|
||||
if (!attachments.length) return prompt;
|
||||
const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
|
||||
return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ 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",
|
||||
"canvas_apply_ops",
|
||||
"canvas_create_node",
|
||||
"canvas_create_attachment_nodes",
|
||||
"canvas_create_text_node",
|
||||
"canvas_create_text_nodes",
|
||||
"canvas_create_config_node",
|
||||
@@ -30,6 +33,14 @@ export const toolNames = [
|
||||
"canvas_select_nodes",
|
||||
"canvas_set_viewport",
|
||||
"canvas_run_generation",
|
||||
"generation_get_status",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
] as const;
|
||||
export type ToolName = (typeof toolNames)[number];
|
||||
|
||||
@@ -37,6 +48,7 @@ export const canvasOpSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("add_node"), nodeType: nodeTypeSchema.optional(), id: z.string().optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), position: positionSchema.optional(), metadata: recordSchema.optional() }).passthrough(),
|
||||
z.object({ type: z.literal("update_node"), id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }).passthrough(),
|
||||
z.object({ type: z.literal("delete_node"), id: z.string().optional(), ids: z.array(z.string()).optional() }).passthrough(),
|
||||
z.object({ type: z.literal("delete_connections"), id: z.string().optional(), ids: z.array(z.string()).optional(), all: z.boolean().optional() }).passthrough(),
|
||||
z.object({ type: z.literal("connect_nodes"), id: z.string().optional(), fromNodeId: z.string(), toNodeId: z.string() }).passthrough(),
|
||||
z.object({ type: z.literal("set_viewport"), viewport: viewportSchema }).passthrough(),
|
||||
z.object({ type: z.literal("select_nodes"), ids: z.array(z.string()) }).passthrough(),
|
||||
@@ -76,11 +88,14 @@ 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(),
|
||||
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
|
||||
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
|
||||
canvas_create_attachment_nodes: z.object({ attachmentIds: z.array(z.string()).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
|
||||
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
@@ -99,14 +114,25 @@ export const toolInputSchemas = {
|
||||
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
|
||||
canvas_set_viewport: z.object({ viewport: viewportSchema }),
|
||||
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
|
||||
generation_get_status: z.object({ scope: z.enum(["all", "canvas", "image", "video"]).optional(), taskId: z.string().optional(), nodeIds: z.array(z.string()).optional(), limit: z.number().optional() }),
|
||||
workbench_image_get_config: z.object({}).passthrough(),
|
||||
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
|
||||
workbench_video_get_config: z.object({}).passthrough(),
|
||||
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: "导出当前画布快照,用于理解布局。",
|
||||
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、connect_nodes、set_viewport、select_nodes、run_generation。",
|
||||
canvas_apply_ops: "批量操作当前网页画布。ops 支持 add_node、update_node、delete_node、delete_connections、connect_nodes、set_viewport、select_nodes、run_generation。",
|
||||
canvas_create_node: "创建任意类型节点:text、image、config、video、audio。适合创建占位图、媒体占位、配置节点或自定义 metadata 节点。",
|
||||
canvas_create_attachment_nodes: "把当前对话中用户上传的图片附件创建成真实画布图片节点。attachmentIds 使用本轮附件清单中的 ID;返回的节点 ID 可传给 canvas_create_generation_flow.referenceNodeIds 作为生成参考图。",
|
||||
canvas_create_text_node: "在当前画布创建单个文本节点。",
|
||||
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
|
||||
@@ -125,4 +151,12 @@ export const toolDescriptions: Record<ToolName, string> = {
|
||||
canvas_select_nodes: "设置当前选中节点。",
|
||||
canvas_set_viewport: "调整画布视口。",
|
||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
generation_get_status: "查询当前活动网页的生成任务状态。默认返回画布、生图工作台和视频工作台最近任务;可用 scope 过滤来源,用 taskId 查询工作台任务,用 nodeIds 查询画布节点。",
|
||||
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,提交后返回 taskId,可用 generation_get_status 查询状态。",
|
||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||
};
|
||||
|
||||
@@ -5,4 +5,4 @@ export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; pos
|
||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string };
|
||||
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
|
||||
|
||||
@@ -10,5 +10,6 @@
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string `env:"PORT" envDefault:"8080"`
|
||||
AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
|
||||
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
|
||||
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
|
||||
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
|
||||
PublicBaseURL string `env:"PUBLIC_BASE_URL"`
|
||||
LinuxDoAuthorizeURL string `env:"LINUX_DO_AUTHORIZE_URL" envDefault:"https://connect.linux.do/oauth2/authorize"`
|
||||
LinuxDoTokenURL string `env:"LINUX_DO_TOKEN_URL" envDefault:"https://connect.linux.do/oauth2/token"`
|
||||
LinuxDoUserInfoURL string `env:"LINUX_DO_USERINFO_URL" envDefault:"https://connect.linux.do/api/user"`
|
||||
}
|
||||
|
||||
var Cfg Config
|
||||
|
||||
func Load() error {
|
||||
_ = godotenv.Load()
|
||||
if err := env.Parse(&Cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeDockerSQLiteDSN("/app/data")
|
||||
if strings.TrimSpace(Cfg.JWTSecret) == "" || Cfg.JWTSecret == "infinite-canvas" {
|
||||
secret, err := randomSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Cfg.JWTSecret = secret
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDockerSQLiteDSN(appDataDir string) {
|
||||
driver := strings.ToLower(strings.TrimSpace(Cfg.StorageDriver))
|
||||
if driver != "" && driver != "sqlite" {
|
||||
return
|
||||
}
|
||||
dsn := strings.TrimSpace(Cfg.DatabaseDSN)
|
||||
if dsn == "" || dsn == ":memory:" || strings.HasPrefix(dsn, "file:") {
|
||||
return
|
||||
}
|
||||
pathPart, suffix := dsn, ""
|
||||
if index := strings.Index(dsn, "?"); index >= 0 {
|
||||
pathPart = dsn[:index]
|
||||
suffix = dsn[index:]
|
||||
}
|
||||
if filepath.IsAbs(pathPart) {
|
||||
return
|
||||
}
|
||||
slashPath := filepath.ToSlash(pathPart)
|
||||
if slashPath != "data" && !strings.HasPrefix(slashPath, "data/") {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(appDataDir); err != nil {
|
||||
return
|
||||
}
|
||||
Cfg.DatabaseDSN = filepath.Join(filepath.Dir(appDataDir), filepath.FromSlash(slashPath)) + suffix
|
||||
}
|
||||
|
||||
func randomSecret() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeDockerSQLiteDSNUsesMountedDataDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
appDataDir := filepath.Join(root, "data")
|
||||
if err := os.MkdirAll(appDataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db?_pragma=busy_timeout(5000)"}
|
||||
|
||||
normalizeDockerSQLiteDSN(appDataDir)
|
||||
|
||||
want := filepath.Join(root, "data", "infinite-canvas.db") + "?_pragma=busy_timeout(5000)"
|
||||
if Cfg.DatabaseDSN != want {
|
||||
t.Fatalf("DatabaseDSN = %q, want %q", Cfg.DatabaseDSN, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDockerSQLiteDSNLeavesLocalPathWithoutMountedDataDir(t *testing.T) {
|
||||
Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db"}
|
||||
|
||||
normalizeDockerSQLiteDSN(filepath.Join(t.TempDir(), "missing-data"))
|
||||
|
||||
if Cfg.DatabaseDSN != "data/infinite-canvas.db" {
|
||||
t.Fatalf("DatabaseDSN = %q, want relative local path", Cfg.DatabaseDSN)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
+4
-4
@@ -2,10 +2,10 @@ services:
|
||||
app:
|
||||
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||
container_name: infinite-canvas
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
# 统计分析(可选,默认关闭)。每家一个独立变量,填了谁就启用谁,可同时启用多家:
|
||||
# environment:
|
||||
# ANALYTICS_GA4_ID: G-XXXXXXXXXX # Google Analytics 4 衡量 ID
|
||||
# ANALYTICS_BAIDU_ID: xxxxxxxxxxxxxxxxxxxxxxxxxxxx # 百度统计站点 ID
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
---
|
||||
title: 接口响应约定
|
||||
description: 业务接口统一响应结构与前端处理约定
|
||||
---
|
||||
|
||||
# 接口响应约定
|
||||
|
||||
后端业务接口统一返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {},
|
||||
"msg": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
- `code`: 业务状态码,`0` 表示成功,非 `0` 表示失败。
|
||||
- `data`: 业务数据。失败时通常为 `null`。
|
||||
- `msg`: 响应消息。成功默认为 `ok`,失败时放错误原因。
|
||||
|
||||
前端请求逻辑以 `code` 判断业务是否成功。当前后端业务失败也会返回 HTTP 200,前端不要只依赖 HTTP 状态码判断结果。
|
||||
|
||||
接口连接失败、服务不可达、返回体不是约定 JSON 时,前端按网络或接口异常处理。
|
||||
@@ -1,200 +0,0 @@
|
||||
---
|
||||
title: 数据库说明
|
||||
description: 当前后端主要数据表与字段说明
|
||||
---
|
||||
|
||||
# 数据库说明
|
||||
|
||||
本文档只记录后端当前已经使用的主要数据表。
|
||||
|
||||
## 数据库
|
||||
|
||||
后端使用 GORM 管理数据库连接和表结构迁移。
|
||||
|
||||
支持的存储驱动:
|
||||
|
||||
- `sqlite`
|
||||
- `mysql`
|
||||
- `postgresql`
|
||||
|
||||
当前启动时执行 `AutoMigrate`,自动维护以下表:
|
||||
|
||||
- `users`
|
||||
- `credit_logs`
|
||||
- `prompts`
|
||||
- `assets`
|
||||
- `settings`
|
||||
|
||||
后续新增表时再同步补充本文档,未实际使用的规划表不提前写入。
|
||||
|
||||
### users
|
||||
|
||||
系统用户表。用户基础信息、角色、算力点余额和第三方登录标识放在该表中。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | 主键 |
|
||||
| `username` | string | 用户名,唯一索引 |
|
||||
| `password` | string | 密码哈希 |
|
||||
| `email` | string | 邮箱 |
|
||||
| `display_name` | string | 昵称 |
|
||||
| `avatar_url` | string | 头像地址 |
|
||||
| `role` | string | 角色:`user`、`admin` |
|
||||
| `credits` | number | 算力点余额 |
|
||||
| `aff_code` | string | 用户自己的邀请码,唯一索引 |
|
||||
| `aff_count` | number | 已邀请用户数量,冗余统计字段 |
|
||||
| `inviter_id` | string | 邀请人用户 ID |
|
||||
| `github_id` | string | GitHub 用户 ID |
|
||||
| `linux_do_id` | string | Linux.do 用户 ID |
|
||||
| `wechat_id` | string | 微信用户 ID |
|
||||
| `status` | string | 用户状态:`active`、`ban` |
|
||||
| `last_login_at` | string | 最近登录时间 |
|
||||
| `extra` | json | 扩展信息,第三方资料按平台命名空间保存,如 `linuxDo` |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### prompts
|
||||
|
||||
提示词表。用于保存公开提示词、内置 GitHub 系统提示词、分类和预览内容。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | 主键 |
|
||||
| `title` | string | 标题 |
|
||||
| `cover_url` | string | 封面图 |
|
||||
| `prompt` | string | 提示词内容 |
|
||||
| `tags` | json | 标签列表 |
|
||||
| `category` | string | 分类标识 |
|
||||
| `preview` | text | Markdown 展示内容,可包含文本、图片、视频链接等 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
`github_url` 仅用于接口返回,不写入数据库。
|
||||
|
||||
### assets
|
||||
|
||||
素材表。当前用于后台素材库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | 主键 |
|
||||
| `title` | string | 标题 |
|
||||
| `type` | string | 素材类型:`text`、`image`、`video` 等 |
|
||||
| `cover_url` | string | 封面图 |
|
||||
| `tags` | json | 标签列表 |
|
||||
| `category` | string | 分类标识 |
|
||||
| `description` | string | 描述 |
|
||||
| `content` | text | 文本或 Markdown 内容 |
|
||||
| `url` | string | 图片、视频等媒体地址 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
### settings
|
||||
|
||||
系统配置表,只保存两行数据:`public` 放前端可读取的公开配置,`private` 放仅后端和管理员可读取的私有配置,配置值都用 JSON。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `key` | string | 主键:`public`、`private` |
|
||||
| `value` | json | 配置内容 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
| `updated_at` | string | 更新时间 |
|
||||
|
||||
`public.value` 常放前端展示和可公开读取的配置,例如模型列表、登录开关等。
|
||||
`private.value` 常放渠道密钥、登录密钥、后台内部开关等。
|
||||
|
||||
当前系统设置接口会按后端结构体序列化和反序列化已知字段;数据库 JSON 中额外存在的旧字段会被忽略。
|
||||
|
||||
`public.value` 当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `modelChannel` | object | 模型渠道公开配置组 |
|
||||
| `auth` | object | 公开登录配置 |
|
||||
|
||||
`modelChannel` 当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `availableModels` | string[] | 系统可用模型列表 |
|
||||
| `modelCosts` | object[] | 模型算力点配置 |
|
||||
| `defaultModel` | string | 默认模型 |
|
||||
| `defaultImageModel` | string | 默认图片模型 |
|
||||
| `defaultVideoModel` | string | 默认视频模型 |
|
||||
| `defaultTextModel` | string | 默认文本模型 |
|
||||
| `systemPrompt` | string | 系统提示词 |
|
||||
| `allowCustomChannel` | bool | 是否允许用户自定义渠道,默认允许,关闭后前端只提供走后端渠道的模式 |
|
||||
|
||||
`modelCosts` 每项字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `model` | string | 模型名称 |
|
||||
| `credits` | number | 每次后端模型接口调用前预扣的算力点,未配置默认不扣除 |
|
||||
|
||||
`auth.linuxDo` 当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 是否开启 Linux.do 登录 |
|
||||
|
||||
`private.value` 当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `channels` | object[] | 模型渠道配置列表 |
|
||||
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||
| `auth` | object | 私有登录配置 |
|
||||
|
||||
`channels` 每项字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `protocol` | string | 协议,当前支持 `openai` |
|
||||
| `name` | string | 渠道名称 |
|
||||
| `baseUrl` | string | 渠道接口地址 |
|
||||
| `apiKey` | string | 渠道密钥 |
|
||||
| `models` | string[] | 渠道可用模型列表 |
|
||||
| `weight` | number | 渠道权重,同一模型命中多个渠道时按权重随机 |
|
||||
| `enabled` | bool | 是否启用 |
|
||||
| `remark` | string | 备注 |
|
||||
|
||||
`promptSync` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 是否开启定时同步,默认开启 |
|
||||
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||
|
||||
`auth.linuxDo` 当前字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `clientId` | string | Linux.do OAuth App Client ID |
|
||||
| `clientSecret` | string | Linux.do OAuth App Client Secret,后台返回时隐藏 |
|
||||
|
||||
后端请求模型时,先按模型名筛选启用且包含该模型的渠道,再按 `weight` 加权随机选择一个渠道。
|
||||
|
||||
### credit_logs
|
||||
|
||||
用户算力点变更流水表。当前记录后台手动调整、模型调用预扣和模型调用失败返还。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | string | 主键 |
|
||||
| `user_id` | string | 关联用户 ID |
|
||||
| `type` | string | 类型:`admin_adjust`、`ai_consume`、`ai_refund` |
|
||||
| `amount` | number | 本次变动数量,增加为正,扣减为负 |
|
||||
| `balance` | number | 变动后的用户算力点余额 |
|
||||
| `related_id` | string | 关联业务 ID,可为空 |
|
||||
| `remark` | string | 备注 |
|
||||
| `extra` | json | 扩展信息 |
|
||||
| `created_at` | string | 创建时间 |
|
||||
|
||||
`type` 当前取值:
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | --- |
|
||||
| `admin_adjust` | 后台手动调整 |
|
||||
| `ai_consume` | 调用后端模型接口消费 |
|
||||
| `ai_refund` | 后端模型接口调用失败返还 |
|
||||
@@ -1,64 +0,0 @@
|
||||
---
|
||||
title: 本地开发
|
||||
description: 前后端分开启动时的本地开发方式
|
||||
---
|
||||
|
||||
# 本地开发
|
||||
|
||||
如果你需要改代码,建议前后端分开启动。
|
||||
|
||||
## 1. 准备环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
默认配置下:
|
||||
|
||||
- 后端端口是 `8080`
|
||||
- 前端端口是 `3000`
|
||||
- SQLite 数据库是 `data/infinite-canvas.db`
|
||||
|
||||
## 2. 启动后端
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
后端会读取根目录 `.env`,并监听:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
## 3. 启动前端
|
||||
|
||||
在 `web` 目录执行:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
前端默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
开发代理默认转发到 `http://127.0.0.1:8080`。如果你的后端端口不同,启动前设置 `API_BASE_URL`。
|
||||
|
||||
## 4. 启动文档站
|
||||
|
||||
如果需要单独调整文档站,在 `docs` 目录执行:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## 常见场景
|
||||
|
||||
- 改画布、页面和交互:主要看 `web/`
|
||||
- 改接口、业务逻辑和数据库:主要看仓库根目录下的 Go 代码
|
||||
- 改文档站内容:主要看 `docs/content/docs/`
|
||||
@@ -1,127 +0,0 @@
|
||||
---
|
||||
title: 系统配置数据结构
|
||||
description: settings 表中 public 和 private 配置结构说明
|
||||
---
|
||||
|
||||
# 系统配置数据结构
|
||||
|
||||
系统配置保存在 `settings` 表中,目前只使用两行:
|
||||
|
||||
| key | 说明 |
|
||||
| --- | --- |
|
||||
| `public` | 公开配置,前端可以读取 |
|
||||
| `private` | 私有配置,只给后端和管理员使用 |
|
||||
|
||||
## public.value
|
||||
|
||||
```json
|
||||
{
|
||||
"modelChannel": {
|
||||
"availableModels": ["gpt-5.5", "gpt-image-2"],
|
||||
"modelCosts": [
|
||||
{ "model": "gpt-5.5", "credits": 1 },
|
||||
{ "model": "gpt-image-2", "credits": 10 }
|
||||
],
|
||||
"defaultModel": "gpt-image-2",
|
||||
"defaultImageModel": "gpt-image-2",
|
||||
"defaultTextModel": "gpt-5.5",
|
||||
"systemPrompt": "",
|
||||
"allowCustomChannel": true
|
||||
},
|
||||
"auth": {
|
||||
"allowRegister": true,
|
||||
"linuxDo": {
|
||||
"enabled": false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `modelChannel` | object | 模型渠道公开配置组 |
|
||||
| `auth` | object | 认证相关公开配置 |
|
||||
|
||||
`modelChannel` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `availableModels` | string[] | 系统可用模型;保存设置时会自动合并所有已启用私有渠道的模型 |
|
||||
| `modelCosts` | object[] | 模型算力点配置,后端模型接口调用前按模型预扣,上游失败时返还;未配置默认不扣除 |
|
||||
| `defaultModel` | string | 默认模型,从 `availableModels` 中选择;为空或失效时优先选择文本模型 |
|
||||
| `defaultImageModel` | string | 默认图片模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedream`、`image`、`gpt-image` 模型 |
|
||||
| `defaultVideoModel` | string | 默认视频模型,从 `availableModels` 中选择;为空或失效时优先选择 `seedance`、`video` 模型 |
|
||||
| `defaultTextModel` | string | 默认文本模型,从 `availableModels` 中选择;为空或失效时优先选择非图片/视频模型 |
|
||||
| `systemPrompt` | string | 系统提示词 |
|
||||
| `allowCustomChannel` | boolean | 是否允许用户在配置弹窗中切换为本地直连渠道,默认允许 |
|
||||
|
||||
`modelCosts` 每项字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `model` | string | 模型名称 |
|
||||
| `credits` | number | 每次后端模型接口调用前预扣的算力点 |
|
||||
|
||||
用户侧请求模式:
|
||||
|
||||
| 模式 | 说明 |
|
||||
| --- | --- |
|
||||
| 云端渠道 | 使用后端 `/api/v1/*` 代理接口,请求会按模型名匹配 `private.value.channels` 中的可用渠道 |
|
||||
| 本地直连 | 默认可选;`allowCustomChannel` 关闭后不可选,用户在浏览器本地配置 `baseUrl`、`apiKey` 和模型列表后直接请求模型接口 |
|
||||
|
||||
`auth` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `allowRegister` | boolean | 是否允许用户注册,默认允许;关闭后注册入口隐藏,注册接口拒绝新用户创建 |
|
||||
| `linuxDo.enabled` | boolean | 是否开启 Linux.do 登录 |
|
||||
|
||||
## private.value
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": [
|
||||
{
|
||||
"protocol": "openai",
|
||||
"name": "默认渠道",
|
||||
"baseUrl": "https://api.example.com",
|
||||
"apiKey": "sk-xxx",
|
||||
"models": ["gpt-5.5", "gpt-image-2"],
|
||||
"weight": 1,
|
||||
"enabled": true,
|
||||
"remark": ""
|
||||
}
|
||||
],
|
||||
"promptSync": {
|
||||
"enabled": true,
|
||||
"cron": "*/5 * * * *"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `channels` | object[] | 模型渠道列表 |
|
||||
| `promptSync` | object | GitHub 远程提示词定时同步配置 |
|
||||
|
||||
`channels` 每项字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `protocol` | string | 协议,当前为 `openai` |
|
||||
| `name` | string | 渠道名称 |
|
||||
| `baseUrl` | string | OpenAI 兼容接口地址 |
|
||||
| `apiKey` | string | 渠道密钥 |
|
||||
| `models` | string[] | 该渠道可用模型 |
|
||||
| `weight` | number | 渠道权重;同一模型有多个可用渠道时按权重随机 |
|
||||
| `enabled` | boolean | 是否启用 |
|
||||
| `remark` | string | 备注 |
|
||||
|
||||
后端调用模型时,会从已启用、已配置 `baseUrl` 和 `apiKey`、且 `models` 包含目标模型的渠道中选择一个。
|
||||
|
||||
`promptSync` 字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | boolean | 是否开启定时同步,默认开启 |
|
||||
| `cron` | string | Cron 表达式,默认每 5 分钟 |
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
title: 贡献者协议
|
||||
description: 贡献代码、文档或素材前需要了解的 CLA
|
||||
---
|
||||
|
||||
# 贡献者协议
|
||||
|
||||
Contributor License Agreement,见 [CLA.md](https://github.com/basketikun/infinite-canvas/blob/main/CLA.md)。
|
||||
|
||||
向本仓库提交 Pull Request、补丁、文档、素材或其他贡献,即表示你同意该协议。维护者也可能要求你在 PR 中明确回复:
|
||||
|
||||
```text
|
||||
I have read and agree to CLA.md.
|
||||
```
|
||||
|
||||
## 贡献前请确认
|
||||
|
||||
- 你有权提交相关代码、文档或素材。
|
||||
- 第三方内容已明确标注来源,并且授权方式与本项目兼容。
|
||||
- 提交内容可以随项目以 AGPL-3.0 以及项目相关商业授权方式发布。
|
||||
- 安全漏洞不要通过公开 Issue 直接披露细节,请按[漏洞提交](/docs/support/security)流程处理。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"license",
|
||||
"cla",
|
||||
"business"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ description: 当前画布节点的主要用途与操作流程
|
||||
- 当文本节点为空时,输入框用于填写想生成的文本内容;点击发送后,结果会回填到当前文本节点。
|
||||
- 当文本节点已有内容时,输入框用于填写想把本段文本修改成什么;点击发送后,会在右侧生成新的文本节点,并自动连接原节点和新节点。
|
||||
- 输入内容可以手写,也可以从提示词库选择。
|
||||
- 输入框支持 `@` 引用已连接的图片、文本、视频、音频资源;`@` 引用图片时会在输入框内直接显示真实缩略图,发送时按当前连接自动编号交给模型。因此不需要再记忆「图片1 / 文本1」编号,画布节点右上角也不再显示资源角标。
|
||||
- 对话框里的模型下拉来自全局配置里已拉取的模型列表;选择结果只作用于当前节点,不会修改其它节点或全局默认模型。
|
||||
- 如果下拉中没有模型,需要先打开配置弹窗拉取模型列表,并设置默认生图模型和默认文本模型。火山方舟 Agent Plan 若提示不支持 `/models`,请手动填写模型名。
|
||||
|
||||
@@ -53,8 +54,8 @@ description: 当前画布节点的主要用途与操作流程
|
||||
- 生成配置节点的视频模式会读取上游文本作为 prompt,读取上游图片作为参考图,读取上游视频作为参考视频,并在输入预览里显示参考视频。
|
||||
- 视频生成接口支持 OpenAI 风格的 `POST /v1/videos`、`GET /v1/videos/{id}` 和 `GET /v1/videos/{id}/content`。
|
||||
- 使用火山方舟 Agent Plan / Seedance 2.0 时,Base URL 配置为 `https://ark.cn-beijing.volces.com/api/plan/v3`,模型名使用 Seedance 2.0 对应模型;系统会改用 `POST /contents/generations/tasks` 创建异步任务,并轮询 `GET /contents/generations/tasks/{id}`。
|
||||
- Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,后台不会伪造模型列表;请手动填写 `doubao-seedance-2.0` 或文档列出的其他可用模型。
|
||||
- Seedance 参考视频必须是公网可访问 URL,或由本项目后端在配置 `PUBLIC_BASE_URL` 后上传并暴露的参考素材 URL。本地/内网地址无法被火山服务器拉取。
|
||||
- Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,配置弹窗不会伪造模型列表;请手动填写 `doubao-seedance-2.0` 或文档列出的其他可用模型。
|
||||
- Seedance 参考视频更建议使用公网可访问 URL;本地素材由前端读取后传给兼容接口,是否可用取决于具体上游。
|
||||
|
||||
### 推荐流程
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ description: 画布常用鼠标与键盘操作说明
|
||||
|
||||
- 拖入图片文件:上传图片到画布。
|
||||
- 导入图片按钮:从本地选择图片。
|
||||
- 素材库或我的素材:选择素材后插入画布。
|
||||
- 我的素材:选择素材后插入画布。
|
||||
|
||||
## 撤销和重做范围
|
||||
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ description: 画布本地存储、节点结构、媒体文件与清理机制
|
||||
|
||||
# 画布数据结构
|
||||
|
||||
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界。
|
||||
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式。
|
||||
|
||||
## 当前存储位置
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
title: 本地开发
|
||||
description: 前端优先的本地开发方式
|
||||
---
|
||||
|
||||
# 本地开发
|
||||
|
||||
当前主应用以 `web/` 前端为主,AI 请求由浏览器前台直连用户自己的 OpenAI 兼容接口。
|
||||
|
||||
## 1. 启动前端
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
前端默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
## 2. 配置模型
|
||||
|
||||
打开右上角配置弹窗,填写自己的 `Base URL`、`API Key` 和模型名。第三方提示词和 WebDAV 都由浏览器前端直连。
|
||||
|
||||
## 3. 启动文档站
|
||||
|
||||
如果需要单独调整文档站,在 `docs` 目录执行:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## 常见场景
|
||||
|
||||
- 改画布、页面和交互:主要看 `web/`
|
||||
- 改提示词缓存:主要看 `web/src/services/api/prompts.ts`
|
||||
- 改文档站内容:主要看 `docs/content/docs/`
|
||||
@@ -4,9 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"api-response",
|
||||
"system-settings",
|
||||
"backend-database",
|
||||
"canvas-data-structure"
|
||||
"canvas-data-structure",
|
||||
"local-codex-canvas"
|
||||
]
|
||||
}
|
||||
@@ -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,不需要手动点击“新建画布”。
|
||||
@@ -12,7 +12,6 @@ description: 使用 Docker Compose 部署无限画布
|
||||
```bash
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -22,19 +21,11 @@ docker compose up -d
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认管理员账号:
|
||||
|
||||
```text
|
||||
用户名:admin
|
||||
密码:.env 中的 ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## 本地构建镜像
|
||||
|
||||
如果需要基于当前源码构建镜像:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
@@ -56,14 +47,45 @@ cd docs
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## 数据目录
|
||||
## 数据说明
|
||||
|
||||
`docker-compose.yml` 会把本地 `./data` 挂载到容器内 `/app/data`,用于保存 SQLite 数据库、提示词数据和上传素材。
|
||||
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||
|
||||
Docker 部署时建议把 `.env` 中的 SQLite 路径设置为:
|
||||
## 统计分析(可选)
|
||||
|
||||
```text
|
||||
DATABASE_DSN=/app/data/infinite-canvas.db
|
||||
项目**默认不接入任何统计**:不设置下列环境变量时,页面不会加载任何统计脚本,也不会发送任何外部请求。你在自己的部署上想查看流量时,设置对应环境变量即可,无需修改代码或重新构建镜像——镜像启动时会据此生成运行期配置。
|
||||
|
||||
统计按「每家一个独立变量」配置:**填了谁就启用谁,可同时启用多家**(例如 GA4 + 百度一起开)。仅支持 GA4 与百度,两者都只接受 ID,脚本地址由代码固定拼接,不接受任意脚本地址或内联 JS。
|
||||
|
||||
| 变量 | 说明 |
|
||||
| --- | --- |
|
||||
| `ANALYTICS_GA4_ID` | Google Analytics 4 衡量 ID(`G-XXXX`) |
|
||||
| `ANALYTICS_BAIDU_ID` | 百度统计站点 ID(`hm.js?` 后面那串 hash) |
|
||||
|
||||
在 `docker-compose.yml` 的 `environment` 中配置(文件内已附注释示例),下例同时启用 GA4 与百度:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
image: ghcr.io/basketikun/infinite-canvas:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
ANALYTICS_GA4_ID: G-XXXXXXXXXX
|
||||
ANALYTICS_BAIDU_ID: e8263761cc5c32f5f7a855b8203b965a
|
||||
```
|
||||
|
||||
如果需要让火山方舟拉取本地上传的 Seedance 参考素材,还需要把 `PUBLIC_BASE_URL` 设置为公网可访问的站点地址。
|
||||
或使用 `docker run`:
|
||||
|
||||
```bash
|
||||
docker run -d -p 3000:3000 \
|
||||
-e ANALYTICS_GA4_ID=G-XXXXXXXXXX \
|
||||
-e ANALYTICS_BAIDU_ID=e8263761cc5c32f5f7a855b8203b965a \
|
||||
ghcr.io/basketikun/infinite-canvas:latest
|
||||
```
|
||||
|
||||
单页应用的路由切换会自动向所有已启用的统计上报 pageview。
|
||||
|
||||
<Callout type="warn">
|
||||
请只在你自己的站点上配置统计 ID,不要把 ID 提交进仓库;fork 或自托管本项目的用户默认不会产生任何统计。
|
||||
</Callout>
|
||||
|
||||
@@ -30,7 +30,7 @@ description: 当前项目已实现的主要功能
|
||||
|
||||
目前画布中有三类节点:
|
||||
|
||||
- 图片节点:展示上传图片、生成图片或素材库图片。
|
||||
- 图片节点:展示上传图片、生成图片或我的素材图片。
|
||||
- 文本节点:保存提示词、说明文案、AI 文字回答等文本内容。
|
||||
- 生成配置节点:汇总上游文本和图片,统一配置模型、比例、数量后批量生成图片或文本。
|
||||
|
||||
@@ -57,16 +57,13 @@ description: 当前项目已实现的主要功能
|
||||
|
||||
## AI 生成
|
||||
|
||||
项目支持两种 AI 调用方式:
|
||||
|
||||
- 本地直连:前端使用本地配置的 Base URL、API Key 和 Model 直接请求 OpenAI 兼容接口。
|
||||
- 后台渠道:前端请求本项目后端 `/api/v1/*` 代理接口,后端按模型选择管理后台配置的渠道。
|
||||
项目默认使用前台直连:前端使用浏览器本地配置的 Base URL、API Key 和 Model 直接请求 OpenAI 兼容接口,不再通过项目服务端转发 AI 请求。
|
||||
|
||||
OpenAI 兼容图像和文本能力继续复用现有接口:
|
||||
|
||||
- `/v1/images/generations`:文生图。
|
||||
- `/v1/images/edits`:图生图/参考图编辑。
|
||||
- `/v1/chat/completions`:文本问答和带图问答。
|
||||
- `/v1/responses`:文本问答、带图问答和在线 Agent 工具调用。
|
||||
- `/v1/models`:读取模型列表;火山方舟 Agent Plan 专属 `/api/plan/v3` 当前未提供 OpenAI `/models` 模型列表接口,需要手动填写模型名。
|
||||
|
||||
视频能力支持两类接口:
|
||||
@@ -76,7 +73,7 @@ OpenAI 兼容图像和文本能力继续复用现有接口:
|
||||
|
||||
Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不会再追加 `/v1`。因此 cpa 反代或火山方舟 Agent Plan 可以继续通过现有 Base URL + API Key + Model 方式配置,不需要新增火山生图 Provider。
|
||||
|
||||
后台“拉取模型列表”会尝试真实请求 OpenAI `/models`,不会为 Agent Plan 伪造模型结果。如果火山方舟 Agent Plan 返回 404,请手动增加 `doubao-seedance-2.0` 或文档列出的其他模型名。
|
||||
配置弹窗里的“拉取模型列表”会尝试真实请求 OpenAI `/models`,不会为 Agent Plan 伪造模型结果。如果火山方舟 Agent Plan 返回 404,请手动增加 `doubao-seedance-2.0` 或文档列出的其他模型名。
|
||||
|
||||
可配置项:
|
||||
|
||||
@@ -88,10 +85,13 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 生成数量。
|
||||
- 视频模型。
|
||||
- 视频比例、清晰度、时长、生成声音和水印。
|
||||
- 自定义生图 / 视频接口的调用方式,灵活适配不同中转站与自建服务。
|
||||
|
||||
普通图片/文本节点可以直接输入提示词生成结果。生成配置节点可以读取上游节点内容,并按节点自己的配置批量生成多个图片或文本结果。生成配置节点支持预览当前提示词和参考图输入,并调整输入顺序。
|
||||
|
||||
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。Seedance 参考视频和参考音频需要公网可访问 URL;本地上传素材会先通过 `/api/v1/media/references` 保存到服务端,再由 `PUBLIC_BASE_URL` 生成可供火山服务器拉取的公开链接。
|
||||
节点下方对话框和组装提示词输入框都支持 `@` 引用已连接的图片、文本、视频、音频等资源;`@` 引用图片时,输入框内会直接显示该图片的真实缩略图,而不再是「图片1」这类文字编号,发送时会按当前连接自动编号交给模型理解。由于引用改在对话框内直接 `@`,画布节点右上角不再显示「图片1 / 文本1」资源角标。
|
||||
|
||||
视频生成可从文本节点读取 prompt,从图片节点读取参考图,从视频节点读取参考视频,从音频节点读取参考音频。Seedance 2.0 支持最多 9 张参考图、3 个参考视频、3 个参考音频;分辨率支持 `480p`、`720p`、`1080p`(fast 模型不支持 `1080p`),比例支持 `16:9`、`4:3`、`1:1`、`3:4`、`9:16`、`21:9`、`adaptive`,时长支持 4-15 秒或智能时长。生成成功后会把视频插入画布为视频节点并使用原生播放器预览。参考视频和参考音频优先使用公网可访问 URL;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
||||
|
||||
## 画布助手
|
||||
|
||||
@@ -109,6 +109,18 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 把助手生成的图片插入画布。
|
||||
- 折叠和展开助手面板。
|
||||
|
||||
## 节点插件系统
|
||||
|
||||
画布支持通过节点插件扩展新的节点类型,内置节点之外的能力都可以以插件形式加载:
|
||||
|
||||
- 支持通过 URL 动态安装、启用、更新和卸载远程节点插件。
|
||||
- 提供官方插件注册表,节点插件面板可从项目仓库读取官方插件列表并一键安装。
|
||||
- 提供 TypeScript 插件开发 SDK,可自行开发画布节点插件,并使用 AI 生成、面板控制等扩展能力。
|
||||
- 内置 Markdown、SVG、HTML、3D 全景、便利贴等示例插件。
|
||||
- 插件面板会显示插件版本,并对可升级的插件给出提醒。
|
||||
|
||||
Codex App 插件的安装与使用见 [Codex App 插件](/docs/overview/codex-app-plugin)。
|
||||
|
||||
## 提示词库
|
||||
|
||||
前台提示词库支持:
|
||||
@@ -121,15 +133,14 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 复制提示词。
|
||||
- 把提示词加入“我的素材”。
|
||||
|
||||
后台提示词管理支持:
|
||||
提示词管理支持:
|
||||
|
||||
- 查询提示词。
|
||||
- 新增、编辑、删除提示词。
|
||||
- 按分组和标签筛选。
|
||||
- 查看远程提示词源。
|
||||
- 同步内置远程提示词源。
|
||||
- 触发读取内置远程提示词源。
|
||||
|
||||
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库。
|
||||
当前内置远程源包括多个 GPT Image / GPT-4o / Nano Banana Pro 相关提示词仓库,由浏览器前端直连拉取并缓存到 IndexedDB。
|
||||
|
||||
## 素材
|
||||
|
||||
@@ -143,49 +154,19 @@ Base URL 如果已经以 `/v1`、`/api/v3` 或 `/api/plan/v3` 结尾,系统不
|
||||
- 分页浏览。
|
||||
- 复制文本素材。
|
||||
- 下载图片素材。
|
||||
- 从提示词库、画布节点和服务器素材库加入素材。
|
||||
- 从提示词库和画布节点加入素材。
|
||||
- 在画布中插入素材。
|
||||
|
||||
“素材库”是服务器素材库,支持:
|
||||
## 配置和同步
|
||||
|
||||
- 按标题搜索。
|
||||
- 按类型筛选。
|
||||
- 按标签筛选。
|
||||
- 查看素材详情。
|
||||
- 复制文本或图片链接。
|
||||
- 加入“我的素材”。
|
||||
- 在画布中插入素材。
|
||||
|
||||
后台素材库管理支持:
|
||||
|
||||
- 查询素材。
|
||||
- 新增、编辑、删除素材。
|
||||
- 按类型和标签筛选。
|
||||
|
||||
## 账号和后台
|
||||
|
||||
- 注册功能暂时关闭。
|
||||
- 仅允许管理员账号登录。
|
||||
- 支持 JWT 会话。
|
||||
- `/api/auth/me` 可读取当前用户,未登录时返回访客用户。
|
||||
- 首次启动时可根据环境变量创建默认管理员。
|
||||
- 管理员后台目前包含提示词管理和素材库管理。
|
||||
- 后端已有用户管理接口,但前端暂未实现用户管理页面。
|
||||
|
||||
## 后端能力
|
||||
|
||||
- Gin 提供 API 服务。
|
||||
- Docker 运行时由 Next.js 提供页面入口,`/api/*` 请求代理到内部 Go 服务。
|
||||
- GORM 管理数据库连接和自动迁移。
|
||||
- 支持 SQLite、MySQL、PostgreSQL。
|
||||
- 数据库保存用户、提示词分组、提示词和服务器素材。
|
||||
- 业务接口统一返回 `{ code, data, msg }`。
|
||||
- 当前版本不需要账号登录,也不再提供后台管理页面。
|
||||
- 配置与用户偏好弹窗支持多个 OpenAI 兼容渠道、默认模型、生成偏好和 WebDAV 同步设置。
|
||||
- 第三方提示词、WebDAV 和 AI 接口都由浏览器前端直连,不经过项目后端代理。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合本地或个人使用,公网多人使用不安全。公网部署推荐使用后台渠道,把真实密钥保存在服务端配置中。
|
||||
- 服务器素材库目前主要保存 URL 或文本,暂未提供文件上传接口。
|
||||
- Seedance 本地参考图/视频上传依赖 `PUBLIC_BASE_URL`,如果服务部署在 localhost、内网或不可被火山访问的地址,火山无法拉取参考素材。
|
||||
- AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合个人或可信环境使用。
|
||||
- Seedance 本地参考视频/音频更建议使用公网可访问 URL;上游是否接受前端传入的本地数据取决于具体兼容接口。
|
||||
- Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 URL 的有效期。
|
||||
- 画布更适合桌面端使用,移动端触控体验还未系统完善。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"quick-start",
|
||||
"codex-app-plugin",
|
||||
"features",
|
||||
"render",
|
||||
"docker",
|
||||
|
||||
@@ -5,15 +5,20 @@ description: 用最少步骤把无限画布跑起来
|
||||
|
||||
# 快速开始
|
||||
|
||||
如果你只是想先把项目跑起来,优先使用 Docker。
|
||||
如果你只是想先把项目跑起来,优先部署或启动 `web/` 前端。
|
||||
|
||||
## Docker 启动
|
||||
## Vercel 部署
|
||||
|
||||
在 Vercel 中导入仓库即可,根目录 `vercel.json` 会构建 `web/`。当前版本的 AI 请求由浏览器前台直连用户自己的 OpenAI 兼容地址,不需要额外配置服务端。
|
||||
|
||||
## 本地启动
|
||||
|
||||
```bash
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
cd web
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
@@ -22,29 +27,22 @@ docker compose up -d
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认管理员账号:
|
||||
## Docker 启动
|
||||
|
||||
```text
|
||||
用户名:admin
|
||||
密码:.env 中的 ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## 本地构建镜像启动
|
||||
|
||||
如果你需要基于当前源码本地构建镜像:
|
||||
如果你需要基于当前源码构建镜像:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
docker build -t infinite-canvas .
|
||||
docker run --rm -p 3000:3000 infinite-canvas
|
||||
```
|
||||
|
||||
## 首次使用建议
|
||||
|
||||
- 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。
|
||||
- 如果使用后台渠道模式,再去管理后台补充系统模型与渠道配置。
|
||||
- 如果需要提示词仓库内容,可进入 `/admin/prompts` 拉取或同步。
|
||||
- 如果需要提示词仓库内容,打开 `/prompts` 会由浏览器前端直连拉取并缓存到 IndexedDB。
|
||||
- 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 WebDAV。
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不支持云同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,WebDAV 同步需要用户自行配置。
|
||||
- AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
|
||||
@@ -13,7 +13,7 @@ description: 使用 Render 部署无限画布
|
||||
|
||||
1. 点击 `Deploy to Render`。
|
||||
2. 登录 Render,并按页面提示连接 GitHub。
|
||||
3. 填写 `ADMIN_PASSWORD`,然后点击确认部署。
|
||||
3. 确认部署。
|
||||
|
||||
部署完成后,打开 Render 分配的 `.onrender.com` 域名即可访问。
|
||||
|
||||
@@ -22,17 +22,7 @@ description: 使用 Render 部署无限画布
|
||||
默认使用 Render 免费 Web Service:
|
||||
|
||||
- 空闲约 15 分钟后会休眠,下次访问会自动唤醒。
|
||||
- 免费版本地文件不是持久化存储,SQLite 数据可能在重启、重新部署后丢失。
|
||||
- 当前主应用数据默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取。
|
||||
- 适合体验和演示,不适合长期保存正式数据。
|
||||
|
||||
如果要长期使用,建议升级 Render 付费实例并挂载 Persistent Disk,或改用 PostgreSQL。
|
||||
|
||||
## 管理员账号
|
||||
|
||||
默认管理员用户名:
|
||||
|
||||
```text
|
||||
admin
|
||||
```
|
||||
|
||||
管理员密码是在 Render 部署页面里填写的 `ADMIN_PASSWORD`。
|
||||
长期使用建议优先部署到 Vercel,或自行配置 WebDAV 同步浏览器本地数据。
|
||||
|
||||
@@ -7,7 +7,6 @@ description: 当前已接入同步逻辑的第三方提示词仓库
|
||||
|
||||
| 地址 | 状态 |
|
||||
| --- | --- |
|
||||
| https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/ZeroLu/awesome-gpt-image | 已实现同步逻辑 |
|
||||
| https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts | 已实现同步逻辑 |
|
||||
| https://github.com/YouMind-OpenLab/awesome-gpt-image-2 | 已实现同步逻辑 |
|
||||
|
||||
@@ -50,7 +50,7 @@ canvas-agent/
|
||||
|
||||
原因:
|
||||
|
||||
- 这是用户本机运行的 Node 服务,不属于线上 Go 后端,也不属于纯前端页面。
|
||||
- 这是用户本机运行的 Node 服务,不属于线上服务端,也不属于纯前端页面。
|
||||
- 后续可以发布成 npm 包,用户通过 `npx` 或全局安装启动。
|
||||
- Codex SDK、Codex MCP、Claude SDK 都更适合在本地 Node 进程中接入。
|
||||
- HTTP 路由使用 Express,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod`,避免手写协议和松散 JSON。
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"pages": [
|
||||
"[更新日志](/docs/progress/changelog)",
|
||||
"local-agent-integration-plan",
|
||||
"prompt-chip-input-plan",
|
||||
"pending-test",
|
||||
"todo"
|
||||
]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: 提示词面板引用显示图片缩略图规划
|
||||
description: 将提示词面板输入框从 textarea 改造为 contentEditable,让 @ 引用直接显示真实图片缩略图
|
||||
---
|
||||
|
||||
# 提示词面板 @ 引用显示真实图片缩略图
|
||||
|
||||
## 背景 / 要解决的问题
|
||||
|
||||
提示词面板(`CanvasNodePromptPanel`)的输入框里,`@` 引用一张图片后,显示的是蓝色文字 chip「图片1」。期望**直接显示这张图片的真实缩略图**,而不是文字编号。
|
||||
|
||||
当前该输入框是原生 `<textarea>`,只能承载纯文本,无法内嵌图片。要显示缩略图,必须改用 `contentEditable`(可编辑 div),把引用做成带 `<img>` 的原子小卡片。项目里「组装提示词」组件(`CanvasConfigComposer`)已经是这套 contentEditable + 图片 chip 的成熟实现,可作蓝本。
|
||||
|
||||
## 关键约束(为什么不能直接改现有组件)
|
||||
|
||||
`CanvasResourceMentionTextarea` 被两处复用:
|
||||
|
||||
1. **提示词面板** `web/src/components/canvas/canvas-node-prompt-panel.tsx` —— 本次要改的对象。`highlightLabels` 默认 true,value 是提示词字符串,引用以 label 文本「图片1」嵌在其中,**直接发给 AI**(配合 `web/src/lib/image-reference-prompt.ts` 的「参考图片编号:图片1」前缀)。
|
||||
2. **节点内文本编辑** `web/src/components/canvas/canvas-node.tsx`(`isEditingContent` 分支)—— `highlightLabels={false}`,通过 `ref` 调用 `textarea.setSelectionRange(...)` 等 **textarea 专有 API**。若把组件改成 contentEditable,这处会直接崩。
|
||||
|
||||
> 结论:**新建一个 contentEditable chip 输入组件,只替换提示词面板那一处**;`CanvasResourceMentionTextarea`(textarea 版)保持不动,继续服务节点内文本编辑。
|
||||
|
||||
## 方案:新增 `CanvasPromptChipInput` 组件
|
||||
|
||||
新文件 `web/src/components/canvas/canvas-prompt-chip-input.tsx`,参照 `canvas-config-composer.tsx` 的 contentEditable 模式,但有一处**核心差异**:
|
||||
|
||||
- 「组装提示词」把引用序列化成 `@[node:UUID]` 标记;
|
||||
- 本组件的 value 必须保持**引用的 label 文本**(如「图片1」),这样发给生成的提示词语义不变。因此 chip ↔ 文本的序列化用 **label 文本**,不是 node 标记。
|
||||
|
||||
### 组件契约(与现用法一致)
|
||||
|
||||
Props:`value: string`、`references: CanvasResourceReference[]`、`onChange(value)`、`onSubmit?()`、`className`、`style`、`placeholder`。图片缩略图取 `reference.previewUrl`(即 `node.metadata.content`,已存在于 `CanvasResourceReference`)。
|
||||
|
||||
### 实现要点(大量复用 composer 的做法)
|
||||
|
||||
1. **DOM ← value(未聚焦时重建)**:仿 `CanvasConfigComposer` 的重建 effect。用 active label 列表(`references.filter(active).map(label)`,按长度降序)把 value 字符串切成「文本片段 + 命中 label」,命中处插入 chip。聚焦时不重建(`document.activeElement === editor` 直接 return),避免与输入 / IME 打架。
|
||||
2. **chip 渲染**:仿 `createReferenceChip`。image 类型 → `size-6` 的 `<img src={reference.previewUrl}>`;其它类型 → 截断文字。chip 为 `contentEditable="false"` 的 `<span>`,用 `dataset.refLabel` 存 label 以便序列化。
|
||||
3. **value ← DOM(syncFromEditor)**:仿 `serializeNodes`,但 chip 序列化为 `dataset.refLabel`(label 文本)而非 node 标记;文本节点原样拼接,`<br>` → `\n`。
|
||||
4. **@ 菜单**:复用 `textBeforeCaret` + `/@([^\s@]*)$/` 检测;菜单定位改用 `getSelection().getRangeAt(0).getBoundingClientRect()`(比 textarea 的镜像 div 更简单、天然贴着光标)。菜单 UI 沿用现有 `MentionMenu` 的视觉(缩略图 + label + 副标题)。
|
||||
5. **原子删除**:直接复用 composer 的 `deleteAdjacentReference` / `adjacentReferenceNode` / `findReferenceSibling`——contentEditable + `contentEditable="false"` chip 天然整体删除。可把这几个通用 DOM 助手连同 `textBeforeCaret` / `placeCaretAtEnd` / `closestEditor` 复制进新组件(保持 composer 不动,规避回归风险)。
|
||||
6. **回车发送 + IME**:复用 `isPlainEnterKey` / `isImeComposing`(`@/lib/keyboard-event`)与 `onCompositionStart/End`,发送时调用 `onSubmit`。
|
||||
7. **占位符 & 样式**:空值时显示 placeholder(absolute 占位层);容器套用面板传入的 `className/style`(`min-h-40`、透明底、无边框,与当前一致)。
|
||||
|
||||
### 替换点
|
||||
|
||||
`canvas-node-prompt-panel.tsx` 里把 `<CanvasResourceMentionTextarea .../>` 换成 `<CanvasPromptChipInput .../>`(同样的 value/references/onChange/onSubmit/className/style/placeholder)。其余(模型选择、设置、生成按钮)不动。
|
||||
|
||||
### 不改动
|
||||
|
||||
- `CanvasResourceMentionTextarea`(textarea 版)保留,节点内文本编辑继续用它。
|
||||
- 生成链路、`image-reference-prompt.ts` 前缀逻辑不变(value 仍是 label 文本)。
|
||||
|
||||
## 影响文件
|
||||
|
||||
- 新增:`web/src/components/canvas/canvas-prompt-chip-input.tsx`
|
||||
- 修改:`web/src/components/canvas/canvas-node-prompt-panel.tsx`(仅替换输入框组件 + 相应 import)
|
||||
|
||||
## 验证
|
||||
|
||||
1. `npx tsc --noEmit` 通过。
|
||||
2. dev server 刷新后:
|
||||
- 连一张图片到图片 / 视频节点 → 面板输入框输入 `@` → 菜单贴着光标弹出 → 选中 → **输入框内显示该图的真实缩略图 chip**(不再是「图片1」文字)。
|
||||
- 光标在 chip 后按一次 Backspace → 整个 chip 一次删除。
|
||||
- 普通文字 + chip 混排,回车触发生成;确认发给生成的 prompt 里引用仍是「图片1」文本(生成结果正常引用到图)。
|
||||
- 中文输入法输入不吞字、不误触发发送。
|
||||
- 回归:双击文本节点进入内文本编辑仍正常(未受影响)。
|
||||
@@ -1,25 +0,0 @@
|
||||
---
|
||||
title: 打赏支持
|
||||
description: 支持无限画布项目继续维护
|
||||
---
|
||||
|
||||
# 打赏支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
|
||||
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
|
||||
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"title": "赞助支持",
|
||||
"title": "支持与安全",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"donate",
|
||||
"security",
|
||||
"sponsor"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
title: 漏洞提交
|
||||
description: 安全漏洞提交和负责任披露说明
|
||||
---
|
||||
|
||||
# 漏洞提交
|
||||
|
||||
安全策略英文原文见 [SECURITY.md](https://github.com/basketikun/infinite-canvas/blob/main/SECURITY.md)。
|
||||
|
||||
请不要在公开 Issue 中直接发布漏洞细节、可利用代码、私密 API Key、截图中的敏感信息或真实用户数据。
|
||||
|
||||
## 提交流程
|
||||
|
||||
优先使用 GitHub 的私密漏洞报告或 Security Advisory。如果仓库没有开启对应入口,请发送邮件至 [1844025705@qq.com](mailto:1844025705@qq.com),邮件标题建议使用:
|
||||
|
||||
```text
|
||||
[infinite-canvas security]
|
||||
```
|
||||
|
||||
如果暂时无法使用私密渠道,可以先开一个公开 Issue 请求维护者提供私密联系方式,但不要在 Issue 中写入漏洞细节。
|
||||
|
||||
## 建议提供的信息
|
||||
|
||||
- 受影响的版本、提交、分支或部署方式。
|
||||
- 清晰的复现步骤。
|
||||
- 漏洞影响、攻击场景和触发条件。
|
||||
- 已脱敏的日志、截图或 PoC。
|
||||
- 是否涉及浏览器本地存储、API Key、WebDAV 同步、AI 渠道配置或接口请求。
|
||||
|
||||
## 范围说明
|
||||
|
||||
项目会优先处理由本仓库代码或默认配置导致的 XSS、敏感信息泄露、文件处理、导入导出、WebDAV 同步、权限控制和供应链风险。
|
||||
|
||||
第三方模型服务、托管平台、浏览器插件、用户自行泄露的 API Key、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。
|
||||
@@ -1,19 +1,33 @@
|
||||
---
|
||||
title: 广告赞助
|
||||
description: 广告赞助、项目露出和社区推广说明
|
||||
title: 赞助支持
|
||||
description: 赞助支持、广告合作、项目露出和社区推广说明
|
||||
---
|
||||
|
||||
# 广告赞助
|
||||
# 赞助支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过 Star、广告赞助合作、提供 AI 订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。相关支持会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 通过广告赞助合作支持项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
## 广告赞助
|
||||
|
||||
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
||||
|
||||
## 适合的赞助内容
|
||||
### 适合的赞助内容
|
||||
|
||||
- AI 工具、模型服务、API 服务或开发者工具。
|
||||
- 与图片、视频、文本生成工作流相关的产品。
|
||||
- 适合开源项目用户和 AI 创作者使用的服务。
|
||||
|
||||
## 建议提供的信息
|
||||
### 建议提供的信息
|
||||
|
||||
- 品牌或产品名称。
|
||||
- 希望展示的位置和周期。
|
||||
|
||||
+13
-12
@@ -13,24 +13,21 @@
|
||||
- [画布节点操作手册](/docs/canvas/canvas-node-manual)
|
||||
- [画布快捷键](/docs/canvas/canvas-shortcuts)
|
||||
|
||||
## 开发文档
|
||||
## 开发与数据
|
||||
|
||||
- [本地开发](/docs/backend/local-development)
|
||||
- [接口响应约定](/docs/backend/api-response)
|
||||
- [系统配置数据结构](/docs/backend/system-settings)
|
||||
- [后端数据库说明](/docs/backend/backend-database)
|
||||
- [画布数据结构](/docs/backend/canvas-data-structure)
|
||||
- [本地开发](/docs/development/local-development)
|
||||
- [画布数据结构](/docs/development/canvas-data-structure)
|
||||
|
||||
## 商务合作
|
||||
|
||||
- [开源协议](/docs/business/license)
|
||||
- [贡献者协议](/docs/business/cla)
|
||||
- [商务合作](/docs/business/business)
|
||||
|
||||
## 赞助支持
|
||||
## 支持与安全
|
||||
|
||||
- [文档](/docs/support/docs)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
- [漏洞提交](/docs/support/security)
|
||||
- [赞助支持](/docs/support/sponsor)
|
||||
|
||||
## 项目进度
|
||||
|
||||
@@ -40,5 +37,9 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不支持云同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,跨设备可自行配置 WebDAV 同步。
|
||||
- AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
|
||||
## 原理说明
|
||||
|
||||
- [本地 Codex 连接画布原理](/docs/development/local-codex-canvas)
|
||||
|
||||
@@ -7,10 +7,10 @@ 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' },
|
||||
{ title: '赞助支持', href: '/docs/support/sponsor', prefix: '/docs/support' },
|
||||
];
|
||||
|
||||
export function DocsTopTabs() {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"installCommand": "bun install",
|
||||
"buildCommand": "bun run build"
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
module github.com/basketikun/infinite-canvas
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/go-sql-driver/mysql v1.8.1
|
||||
github.com/jackc/pgx/v5 v5.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.3.0
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
@@ -1,140 +0,0 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -1,74 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
type adminSyncRequest struct {
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type adminBatchDeleteRequest struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
|
||||
func AdminPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||
OK(w, service.ListPromptCategories())
|
||||
}
|
||||
|
||||
func AdminPrompts(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListPrompts(parseQuery(r))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminSavePrompt(w http.ResponseWriter, r *http.Request) {
|
||||
var item model.Prompt
|
||||
_ = json.NewDecoder(r.Body).Decode(&item)
|
||||
result, err := service.SavePrompt(item)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminDeletePrompt(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeletePrompt(id); err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
|
||||
func AdminDeletePrompts(w http.ResponseWriter, r *http.Request) {
|
||||
var request adminBatchDeleteRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
if err := service.DeletePrompts(request.IDs); err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
|
||||
func AdminSyncPromptCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var request adminSyncRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
log.Printf("sync prompt category start category=%s", request.Category)
|
||||
categories, err := service.SyncPromptCategory(request.Category)
|
||||
if err != nil {
|
||||
log.Printf("sync prompt category failed category=%s err=%v", request.Category, err)
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
log.Printf("sync prompt category done category=%s", request.Category)
|
||||
OK(w, categories)
|
||||
}
|
||||
-311
@@ -1,311 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func AIImagesGenerations(w http.ResponseWriter, r *http.Request) {
|
||||
proxyAIRequest(w, r, "/images/generations")
|
||||
}
|
||||
|
||||
func AIImagesEdits(w http.ResponseWriter, r *http.Request) {
|
||||
proxyAIRequest(w, r, "/images/edits")
|
||||
}
|
||||
|
||||
func AIChatCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
proxyAIRequest(w, r, "/chat/completions")
|
||||
}
|
||||
|
||||
func AIAudioSpeech(w http.ResponseWriter, r *http.Request) {
|
||||
proxyAIRequest(w, r, "/audio/speech")
|
||||
}
|
||||
|
||||
func AIVideos(w http.ResponseWriter, r *http.Request) {
|
||||
proxyAIRequest(w, r, "/videos")
|
||||
}
|
||||
|
||||
func AIVideo(w http.ResponseWriter, r *http.Request, id string) {
|
||||
proxyAIGetRequest(w, r, "/videos/"+id)
|
||||
}
|
||||
|
||||
func AIVideoContent(w http.ResponseWriter, r *http.Request, id string) {
|
||||
proxyAIGetRequest(w, r, "/videos/"+id+"/content")
|
||||
}
|
||||
|
||||
func proxyAIGetRequest(w http.ResponseWriter, r *http.Request, path string) {
|
||||
modelName := r.URL.Query().Get("model")
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
modelName = "grok-imagine-video"
|
||||
}
|
||||
channel, err := service.SelectModelChannel(modelName)
|
||||
if err != nil {
|
||||
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
|
||||
request, err := http.NewRequest(http.MethodGet, service.BuildModelChannelURL(channel, path), nil)
|
||||
if err != nil {
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
|
||||
copyAIResponse(w, request, nil)
|
||||
}
|
||||
|
||||
func proxyAIRequest(w http.ResponseWriter, r *http.Request, path string) {
|
||||
body, contentType, modelName, err := readAIRequest(r)
|
||||
if err != nil {
|
||||
log.Printf("AI proxy request read failed: %v", err)
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
user, ok := service.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
Fail(w, "未登录或权限不足")
|
||||
return
|
||||
}
|
||||
credits, err := service.ModelCost(modelName)
|
||||
if err != nil {
|
||||
log.Printf("AI proxy read model cost failed: model=%s err=%v", modelName, err)
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
credits *= readAIRequestCount(body, contentType)
|
||||
channel, err := service.SelectModelChannel(modelName)
|
||||
if err != nil {
|
||||
log.Printf("AI proxy select channel failed: model=%s err=%v", modelName, err)
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
path = resolveAIProxyPath(channel.BaseURL, modelName, path)
|
||||
request, err := http.NewRequest(http.MethodPost, service.BuildModelChannelURL(channel, path), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
log.Printf("AI proxy build request failed: url=%s err=%v", service.BuildModelChannelURL(channel, path), err)
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
|
||||
if contentType != "" {
|
||||
request.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
if err := service.ConsumeUserCredits(user.ID, modelName, credits, path); err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
copyAIResponse(w, request, func() {
|
||||
if err := service.RefundUserCredits(user.ID, modelName, credits, path); err != nil {
|
||||
log.Printf("AI proxy refund credits failed: user=%s model=%s credits=%d err=%v", user.ID, modelName, credits, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func copyAIResponse(w http.ResponseWriter, request *http.Request, onFailure func()) {
|
||||
response, err := http.DefaultClient.Do(request)
|
||||
if err != nil {
|
||||
log.Printf("AI proxy request failed: url=%s err=%v", request.URL.String(), err)
|
||||
if onFailure != nil {
|
||||
onFailure()
|
||||
}
|
||||
Fail(w, "AI 接口请求失败")
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
|
||||
log.Printf("AI upstream error: url=%s status=%d", request.URL.String(), response.StatusCode)
|
||||
if onFailure != nil {
|
||||
onFailure()
|
||||
}
|
||||
Fail(w, aiUpstreamStatusMessage(response.StatusCode, body))
|
||||
return
|
||||
}
|
||||
|
||||
for key, values := range response.Header {
|
||||
if strings.EqualFold(key, "Content-Length") {
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
w.Header().Add(key, value)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(response.StatusCode)
|
||||
_, _ = io.Copy(w, response.Body)
|
||||
}
|
||||
|
||||
func readAIRequest(r *http.Request) ([]byte, string, string, error) {
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
modelName := ""
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
modelName = readMultipartModel(body, contentType)
|
||||
} else {
|
||||
var payload struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
modelName = payload.Model
|
||||
}
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
return nil, "", "", errMissingModel
|
||||
}
|
||||
return body, contentType, modelName, nil
|
||||
}
|
||||
|
||||
func readMultipartModel(body []byte, contentType string) string {
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
reader := multipart.NewReader(bytes.NewReader(body), params["boundary"])
|
||||
form, err := reader.ReadForm(32 << 20)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
if values := form.Value["model"]; len(values) > 0 {
|
||||
return values[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readAIRequestCount(body []byte, contentType string) int {
|
||||
count := 1
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
_, params, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
return count
|
||||
}
|
||||
form, err := multipart.NewReader(bytes.NewReader(body), params["boundary"]).ReadForm(32 << 20)
|
||||
if err != nil {
|
||||
return count
|
||||
}
|
||||
defer form.RemoveAll()
|
||||
if values := form.Value["n"]; len(values) > 0 {
|
||||
_, _ = fmt.Sscan(values[0], &count)
|
||||
}
|
||||
} else {
|
||||
var payload struct {
|
||||
N int `json:"n"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
count = payload.N
|
||||
}
|
||||
if count < 1 {
|
||||
return 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
var errMissingModel = &aiError{"缺少模型名称"}
|
||||
|
||||
func resolveAIProxyPath(baseURL string, modelName string, path string) string {
|
||||
if !isArkSeedanceVideo(baseURL, modelName) {
|
||||
return path
|
||||
}
|
||||
if path == "/videos" {
|
||||
return "/contents/generations/tasks"
|
||||
}
|
||||
if strings.HasPrefix(path, "/videos/") && !strings.HasSuffix(path, "/content") {
|
||||
return "/contents/generations/tasks/" + strings.TrimPrefix(path, "/videos/")
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func isArkSeedanceVideo(baseURL string, modelName string) bool {
|
||||
base := strings.ToLower(baseURL)
|
||||
model := strings.ToLower(modelName)
|
||||
return strings.Contains(model, "seedance") || strings.Contains(model, "doubao-seedance") || strings.Contains(base, "/api/plan/v3")
|
||||
}
|
||||
|
||||
func aiStatusMessage(statusCode int) string {
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized, http.StatusForbidden:
|
||||
return "AI 接口鉴权失败,请检查 API Key、套餐权限或模型权限"
|
||||
case http.StatusTooManyRequests:
|
||||
return "AI 接口限流或额度不足,请稍后重试或检查额度"
|
||||
default:
|
||||
return "AI 接口请求失败"
|
||||
}
|
||||
}
|
||||
|
||||
func aiUpstreamStatusMessage(statusCode int, body []byte) string {
|
||||
base := aiStatusMessage(statusCode)
|
||||
detail := aiUpstreamErrorDetail(body)
|
||||
if detail == "" {
|
||||
return base
|
||||
}
|
||||
return base + ":" + detail
|
||||
}
|
||||
|
||||
func aiUpstreamErrorDetail(body []byte) string {
|
||||
text := strings.TrimSpace(string(body))
|
||||
if text == "" {
|
||||
return ""
|
||||
}
|
||||
var payload struct {
|
||||
Msg string `json:"msg"`
|
||||
Message string `json:"message"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err == nil {
|
||||
if payload.Error.Message != "" {
|
||||
if detail := friendlyUpstreamError(payload.Error.Code, payload.Error.Message); detail != "" {
|
||||
return safeUpstreamText(detail)
|
||||
}
|
||||
if payload.Error.Code != "" {
|
||||
return safeUpstreamText(payload.Error.Code + " " + payload.Error.Message)
|
||||
}
|
||||
return safeUpstreamText(payload.Error.Message)
|
||||
}
|
||||
if payload.Msg != "" {
|
||||
return safeUpstreamText(payload.Msg)
|
||||
}
|
||||
if payload.Message != "" {
|
||||
return safeUpstreamText(payload.Message)
|
||||
}
|
||||
}
|
||||
return safeUpstreamText(text)
|
||||
}
|
||||
|
||||
func friendlyUpstreamError(code string, message string) string {
|
||||
lowerCode := strings.ToLower(strings.TrimSpace(code))
|
||||
if strings.Contains(lowerCode, "inputvideosensitivecontentdetected") || strings.Contains(lowerCode, "privacyinformation") {
|
||||
return strings.TrimSpace(code + " 参考视频疑似包含真人或隐私信息,火山方舟拒绝使用普通 URL 作为真人视频参考;请改用不含真人的视频、官方允许的模型产物,或已授权的 asset:// 素材。原始错误:" + message)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safeUpstreamText(text string) string {
|
||||
text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
|
||||
runes := []rune(text)
|
||||
if len(runes) > 300 {
|
||||
return string(runes[:300]) + "..."
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
type aiError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (err *aiError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAIUpstreamErrorDetail(t *testing.T) {
|
||||
got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InvalidParameter","message":"reference video fps is invalid"}}`))
|
||||
if got != "InvalidParameter reference video fps is invalid" {
|
||||
t.Fatalf("detail = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIUpstreamErrorDetailExplainsSensitiveVideo(t *testing.T) {
|
||||
got := aiUpstreamErrorDetail([]byte(`{"error":{"code":"InputVideoSensitiveContentDetected.PrivacyInformation","message":"The request failed because the input video may contain real person."}}`))
|
||||
if !strings.Contains(got, "参考视频疑似包含真人") || !strings.Contains(got, "asset://") {
|
||||
t.Fatalf("detail = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafeUpstreamTextTruncates(t *testing.T) {
|
||||
got := safeUpstreamText(strings.Repeat("错", 320))
|
||||
if len([]rune(got)) != 303 {
|
||||
t.Fatalf("truncated rune length = %d", len([]rune(got)))
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func Assets(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListAssets(parseQuery(r))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminAssets(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListAssets(parseQuery(r))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminSaveAsset(w http.ResponseWriter, r *http.Request) {
|
||||
var item model.Asset
|
||||
_ = json.NewDecoder(r.Body).Decode(&item)
|
||||
result, err := service.SaveAsset(item)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminDeleteAsset(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeleteAsset(id); err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
-186
@@ -1,186 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
type loginRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type registerRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type saveUserRequest struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role model.UserRole `json:"role"`
|
||||
Status model.UserStatus `json:"status"`
|
||||
}
|
||||
|
||||
type adjustUserCreditsRequest struct {
|
||||
Credits int `json:"credits"`
|
||||
}
|
||||
|
||||
func Register(w http.ResponseWriter, r *http.Request) {
|
||||
var request registerRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
session, err := service.Register(request.Username, request.Password)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, session)
|
||||
}
|
||||
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
var request loginRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
session, err := service.Login(request.Username, request.Password)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, session)
|
||||
}
|
||||
|
||||
func LinuxDoAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
authURL, err := service.LinuxDoAuthorizeURL(r, r.URL.Query().Get("redirect"))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, authURL, http.StatusFound)
|
||||
}
|
||||
|
||||
func LinuxDoCallback(w http.ResponseWriter, r *http.Request) {
|
||||
session, redirect, err := service.LoginWithLinuxDo(r, r.URL.Query().Get("code"), r.URL.Query().Get("state"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, loginRedirect(r, redirect, "", err.Error()), http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, loginRedirect(r, redirect, session.Token, ""), http.StatusFound)
|
||||
}
|
||||
|
||||
func AdminLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var request loginRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
session, err := service.Login(request.Username, request.Password)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
if session.User.Role != model.UserRoleAdmin {
|
||||
Fail(w, "需要管理员权限")
|
||||
return
|
||||
}
|
||||
OK(w, session)
|
||||
}
|
||||
|
||||
func CurrentUser(w http.ResponseWriter, r *http.Request) {
|
||||
if user, ok := service.UserFromContext(r.Context()); ok {
|
||||
OK(w, user)
|
||||
return
|
||||
}
|
||||
OK(w, service.GuestUser())
|
||||
}
|
||||
|
||||
func AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := service.ListUsers(parseQuery(r))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, users)
|
||||
}
|
||||
|
||||
func AdminSaveUser(w http.ResponseWriter, r *http.Request) {
|
||||
var request saveUserRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
user, err := service.SaveUser(model.User{
|
||||
ID: request.ID,
|
||||
Username: request.Username,
|
||||
Email: request.Email,
|
||||
DisplayName: request.DisplayName,
|
||||
Role: request.Role,
|
||||
Status: request.Status,
|
||||
}, request.Password)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, user)
|
||||
}
|
||||
|
||||
func AdminAdjustUserCredits(w http.ResponseWriter, r *http.Request, id string) {
|
||||
var request adjustUserCreditsRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
user, err := service.AdjustUserCredits(id, request.Credits)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, user)
|
||||
}
|
||||
|
||||
func AdminCreditLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logs, err := service.ListCreditLogs(parseQuery(r))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, logs)
|
||||
}
|
||||
|
||||
func AdminSaveCreditLog(w http.ResponseWriter, r *http.Request) {
|
||||
var log model.CreditLog
|
||||
_ = json.NewDecoder(r.Body).Decode(&log)
|
||||
result, err := service.SaveCreditLog(log)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminDeleteCreditLog(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeleteCreditLog(id); err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
|
||||
func loginRedirect(r *http.Request, redirect string, token string, message string) string {
|
||||
values := url.Values{}
|
||||
if strings.TrimSpace(token) != "" {
|
||||
values.Set("token", token)
|
||||
}
|
||||
if strings.TrimSpace(message) != "" {
|
||||
values.Set("error", message)
|
||||
}
|
||||
if strings.TrimSpace(redirect) != "" {
|
||||
values.Set("redirect", redirect)
|
||||
}
|
||||
return service.RequestOrigin(r) + "/login?" + values.Encode()
|
||||
}
|
||||
|
||||
func AdminDeleteUser(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if err := service.DeleteUser(id); err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, true)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
referenceMediaMaxBytes = 80 << 20
|
||||
referenceImageMaxBytes = 30 << 20
|
||||
referenceVideoMaxBytes = 50 << 20
|
||||
referenceAudioMaxBytes = 15 << 20
|
||||
referenceImageAllowedText = "jpeg/png/webp/bmp/gif/heic/heif 图片"
|
||||
referenceVideoAllowedText = "mp4/mov 视频"
|
||||
referenceAudioAllowedText = "mp3/wav 音频"
|
||||
referenceMediaAllowedText = referenceImageAllowedText + "、" + referenceVideoAllowedText + "或" + referenceAudioAllowedText
|
||||
)
|
||||
|
||||
type referenceMediaUploadResult struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
func UploadReferenceMedia(w http.ResponseWriter, r *http.Request) {
|
||||
publicBaseURL := strings.TrimRight(strings.TrimSpace(config.Cfg.PublicBaseURL), "/")
|
||||
if publicBaseURL == "" {
|
||||
Fail(w, "未配置 PUBLIC_BASE_URL,无法把本地参考素材提供给火山方舟访问")
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, referenceMediaMaxBytes+1)
|
||||
if err := r.ParseMultipartForm(referenceMediaMaxBytes); err != nil {
|
||||
Fail(w, "参考素材过大或上传格式不正确")
|
||||
return
|
||||
}
|
||||
if r.MultipartForm != nil {
|
||||
defer r.MultipartForm.RemoveAll()
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Fail(w, "请上传参考图片或视频")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
mimeType, ext, ok := normalizeReferenceMediaType(header.Header.Get("Content-Type"), filepath.Ext(header.Filename))
|
||||
if !ok {
|
||||
Fail(w, "参考素材格式不支持,请使用 "+referenceMediaAllowedText)
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(referenceMediaDir(), 0o755); err != nil {
|
||||
Fail(w, "参考素材保存失败")
|
||||
return
|
||||
}
|
||||
id := uuid.NewString() + ext
|
||||
targetPath := filepath.Join(referenceMediaDir(), id)
|
||||
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
|
||||
if err != nil {
|
||||
Fail(w, "参考素材保存失败")
|
||||
return
|
||||
}
|
||||
bytes, copyErr := io.Copy(target, file)
|
||||
closeErr := target.Close()
|
||||
if copyErr != nil || closeErr != nil {
|
||||
_ = os.Remove(targetPath)
|
||||
Fail(w, "参考素材保存失败")
|
||||
return
|
||||
}
|
||||
if bytes <= 0 {
|
||||
_ = os.Remove(targetPath)
|
||||
Fail(w, "参考素材为空")
|
||||
return
|
||||
}
|
||||
if limit := referenceMediaTypeMaxBytes(mimeType); limit > 0 && bytes > limit {
|
||||
_ = os.Remove(targetPath)
|
||||
Fail(w, referenceMediaSizeMessage(mimeType))
|
||||
return
|
||||
}
|
||||
OK(w, referenceMediaUploadResult{
|
||||
ID: id,
|
||||
URL: fmt.Sprintf("%s/api/media/references/%s", publicBaseURL, id),
|
||||
MimeType: mimeType,
|
||||
Bytes: bytes,
|
||||
})
|
||||
}
|
||||
|
||||
func ReferenceMedia(w http.ResponseWriter, r *http.Request, id string) {
|
||||
if id == "" || id != filepath.Base(id) || strings.Contains(id, "..") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join(referenceMediaDir(), id)
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if mimeType := mimeTypeByReferenceMediaExt(filepath.Ext(id)); mimeType != "" {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
http.ServeContent(w, r, id, info.ModTime(), file)
|
||||
}
|
||||
|
||||
func referenceMediaDir() string {
|
||||
return filepath.Join(referenceDataDir(), "reference-media")
|
||||
}
|
||||
|
||||
func referenceDataDir() string {
|
||||
driver := strings.ToLower(strings.TrimSpace(config.Cfg.StorageDriver))
|
||||
dsn := strings.TrimSpace(config.Cfg.DatabaseDSN)
|
||||
if (driver == "" || driver == "sqlite") && dsn != "" && dsn != ":memory:" && !strings.HasPrefix(dsn, "file:") {
|
||||
pathPart := dsn
|
||||
if index := strings.Index(dsn, "?"); index >= 0 {
|
||||
pathPart = dsn[:index]
|
||||
}
|
||||
if filepath.IsAbs(pathPart) {
|
||||
return filepath.Dir(pathPart)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("/app/data"); err == nil {
|
||||
return "/app/data"
|
||||
}
|
||||
return "data"
|
||||
}
|
||||
|
||||
func normalizeReferenceMediaType(contentType string, ext string) (string, string, bool) {
|
||||
contentType = strings.ToLower(strings.TrimSpace(strings.Split(contentType, ";")[0]))
|
||||
ext = strings.ToLower(strings.TrimSpace(ext))
|
||||
if contentType == "" || contentType == "application/octet-stream" {
|
||||
contentType = mimeTypeByReferenceMediaExt(ext)
|
||||
}
|
||||
if fixedExt := referenceMediaExtByMimeType(contentType); fixedExt != "" {
|
||||
return contentType, fixedExt, true
|
||||
}
|
||||
if mimeType := mimeTypeByReferenceMediaExt(ext); mimeType != "" {
|
||||
return mimeType, ext, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
func referenceMediaExtByMimeType(mimeType string) string {
|
||||
switch strings.ToLower(mimeType) {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return ".jpg"
|
||||
case "image/png":
|
||||
return ".png"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/bmp":
|
||||
return ".bmp"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "image/heic":
|
||||
return ".heic"
|
||||
case "image/heif":
|
||||
return ".heif"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
case "video/quicktime", "video/mov":
|
||||
return ".mov"
|
||||
case "audio/mpeg", "audio/mp3":
|
||||
return ".mp3"
|
||||
case "audio/wav", "audio/x-wav", "audio/wave":
|
||||
return ".wav"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mimeTypeByReferenceMediaExt(ext string) string {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".jpg", ".jpeg":
|
||||
return "image/jpeg"
|
||||
case ".png":
|
||||
return "image/png"
|
||||
case ".webp":
|
||||
return "image/webp"
|
||||
case ".bmp":
|
||||
return "image/bmp"
|
||||
case ".gif":
|
||||
return "image/gif"
|
||||
case ".heic":
|
||||
return "image/heic"
|
||||
case ".heif":
|
||||
return "image/heif"
|
||||
case ".mp4":
|
||||
return "video/mp4"
|
||||
case ".mov":
|
||||
return "video/quicktime"
|
||||
case ".mp3":
|
||||
return "audio/mpeg"
|
||||
case ".wav":
|
||||
return "audio/wav"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func referenceMediaTypeMaxBytes(mimeType string) int64 {
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
return referenceImageMaxBytes
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "video/") {
|
||||
return referenceVideoMaxBytes
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "audio/") {
|
||||
return referenceAudioMaxBytes
|
||||
}
|
||||
return referenceMediaMaxBytes
|
||||
}
|
||||
|
||||
func referenceMediaSizeMessage(mimeType string) string {
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
return "参考图片超过大小限制,请使用 30MB 以内的图片"
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "video/") {
|
||||
return "参考视频超过大小限制,请使用 50MB 以内的 mp4/mov 视频"
|
||||
}
|
||||
if strings.HasPrefix(mimeType, "audio/") {
|
||||
return "参考音频超过大小限制,请使用 15MB 以内的 mp3/wav 音频"
|
||||
}
|
||||
return "参考素材超过大小限制"
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
)
|
||||
|
||||
func TestNormalizeReferenceMediaTypeSupportsAudio(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
contentType string
|
||||
ext string
|
||||
wantMime string
|
||||
wantExt string
|
||||
}{
|
||||
{name: "mp3 mime", contentType: "audio/mpeg", ext: ".bin", wantMime: "audio/mpeg", wantExt: ".mp3"},
|
||||
{name: "wav ext fallback", contentType: "application/octet-stream", ext: ".wav", wantMime: "audio/wav", wantExt: ".wav"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mimeType, ext, ok := normalizeReferenceMediaType(tt.contentType, tt.ext)
|
||||
if !ok {
|
||||
t.Fatal("expected media type to be accepted")
|
||||
}
|
||||
if mimeType != tt.wantMime || ext != tt.wantExt {
|
||||
t.Fatalf("got (%q, %q), want (%q, %q)", mimeType, ext, tt.wantMime, tt.wantExt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceMediaTypeMaxBytes(t *testing.T) {
|
||||
if got := referenceMediaTypeMaxBytes("audio/mpeg"); got != referenceAudioMaxBytes {
|
||||
t.Fatalf("audio max bytes = %d, want %d", got, referenceAudioMaxBytes)
|
||||
}
|
||||
if got := referenceMediaTypeMaxBytes("video/mp4"); got != referenceVideoMaxBytes {
|
||||
t.Fatalf("video max bytes = %d, want %d", got, referenceVideoMaxBytes)
|
||||
}
|
||||
if got := referenceMediaTypeMaxBytes("image/png"); got != referenceImageMaxBytes {
|
||||
t.Fatalf("image max bytes = %d, want %d", got, referenceImageMaxBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceMediaDirUsesAbsoluteSQLiteDataDir(t *testing.T) {
|
||||
previous := config.Cfg
|
||||
t.Cleanup(func() { config.Cfg = previous })
|
||||
root := t.TempDir()
|
||||
config.Cfg = config.Config{StorageDriver: "sqlite", DatabaseDSN: filepath.Join(root, "infinite-canvas.db")}
|
||||
|
||||
if got := referenceMediaDir(); got != filepath.Join(root, "reference-media") {
|
||||
t.Fatalf("referenceMediaDir = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func Prompts(w http.ResponseWriter, r *http.Request) {
|
||||
result, err := service.ListPrompts(parseQuery(r))
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
)
|
||||
|
||||
type response struct {
|
||||
Code int `json:"code"`
|
||||
Data any `json:"data"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
|
||||
func OK(w http.ResponseWriter, data any) {
|
||||
writeJSON(w, response{Code: 0, Data: data, Msg: "ok"})
|
||||
}
|
||||
|
||||
func Fail(w http.ResponseWriter, msg string) {
|
||||
writeJSON(w, response{Code: 1, Data: nil, Msg: msg})
|
||||
}
|
||||
|
||||
func FailError(w http.ResponseWriter, err error) {
|
||||
log.Printf("request failed: %v", err)
|
||||
if safe, ok := err.(interface{ SafeMessage() string }); ok {
|
||||
Fail(w, safe.SafeMessage())
|
||||
return
|
||||
}
|
||||
Fail(w, "操作失败")
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func parseQuery(r *http.Request) model.Query {
|
||||
q := r.URL.Query()
|
||||
page, _ := strconv.Atoi(q.Get("page"))
|
||||
pageSize, _ := strconv.Atoi(q.Get("pageSize"))
|
||||
return model.Query{
|
||||
Keyword: q.Get("keyword"),
|
||||
Tags: q["tag"],
|
||||
Category: q.Get("category"),
|
||||
Type: q.Get("type"),
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
type adminChannelActionRequest struct {
|
||||
Index *int `json:"index"`
|
||||
Channel model.ModelChannel `json:"channel"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func Settings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := service.PublicSettings()
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, settings)
|
||||
}
|
||||
|
||||
func AdminSettings(w http.ResponseWriter, r *http.Request) {
|
||||
settings, err := service.AdminSettings()
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, settings)
|
||||
}
|
||||
|
||||
func AdminSaveSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var settings model.Settings
|
||||
_ = json.NewDecoder(r.Body).Decode(&settings)
|
||||
result, err := service.SaveSettings(settings)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
|
||||
func AdminChannelModels(w http.ResponseWriter, r *http.Request) {
|
||||
var request adminChannelActionRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
models, err := service.AdminChannelModels(request.Index, request.Channel)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, models)
|
||||
}
|
||||
|
||||
func AdminTestChannelModel(w http.ResponseWriter, r *http.Request) {
|
||||
var request adminChannelActionRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&request)
|
||||
result, err := service.AdminTestChannelModel(request.Index, request.Channel, request.Model)
|
||||
if err != nil {
|
||||
FailError(w, err)
|
||||
return
|
||||
}
|
||||
OK(w, result)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/router"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := config.Load(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := service.EnsureDefaultAdmin(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
service.StartPromptSyncScheduler()
|
||||
log.Fatal(router.New().Run(":" + config.Cfg.Port))
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/handler"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AdminAuth(c *gin.Context) {
|
||||
user, ok := authUser(c)
|
||||
if !ok || user.Role != model.UserRoleAdmin {
|
||||
handler.Fail(c.Writer, "未登录或权限不足")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func UserAuth(c *gin.Context) {
|
||||
user, ok := authUser(c)
|
||||
if !ok || user.Role == model.UserRoleGuest {
|
||||
handler.Fail(c.Writer, "未登录或权限不足")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func OptionalAuth(c *gin.Context) {
|
||||
if user, ok := authUser(c); ok {
|
||||
c.Request = c.Request.WithContext(service.WithUser(c.Request.Context(), user))
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
func NotFoundJSON(c *gin.Context) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"code": 1, "data": nil, "msg": "接口不存在"})
|
||||
}
|
||||
|
||||
func authUser(c *gin.Context) (model.AuthUser, bool) {
|
||||
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
|
||||
if strings.TrimSpace(token) == "" {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
return service.CurrentAuthUser(token)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package model
|
||||
|
||||
type AssetType string
|
||||
|
||||
const (
|
||||
AssetTypeText AssetType = "text"
|
||||
AssetTypeImage AssetType = "image"
|
||||
)
|
||||
|
||||
// Asset 素材记录。
|
||||
type Asset struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
Type AssetType `json:"type"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Tags []string `json:"tags" gorm:"serializer:json"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Content string `json:"content,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// AssetList 素材分页结果。
|
||||
type AssetList struct {
|
||||
Items []Asset `json:"items"`
|
||||
Tags []string `json:"tags"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package model
|
||||
|
||||
// Prompt 提示词记录。
|
||||
type Prompt struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Title string `json:"title"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
Prompt string `json:"prompt"`
|
||||
Tags []string `json:"tags" gorm:"serializer:json"`
|
||||
Category string `json:"category" gorm:"index"`
|
||||
GithubURL string `json:"githubUrl" gorm:"-"`
|
||||
Preview string `json:"preview"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PromptList 提示词分页结果。
|
||||
type PromptList struct {
|
||||
Items []Prompt `json:"items"`
|
||||
Tags []string `json:"tags"`
|
||||
Categories []string `json:"categories"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// PromptCategory 提示词分类。
|
||||
type PromptCategory struct {
|
||||
Category string `json:"category" gorm:"primaryKey"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GithubURL string `json:"githubUrl"`
|
||||
Remote bool `json:"remote"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package model
|
||||
|
||||
const MaxPageSize = 500
|
||||
|
||||
// Query 列表筛选和分页参数。
|
||||
type Query struct {
|
||||
Keyword string
|
||||
Tags []string
|
||||
Category string
|
||||
Type string
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func (q *Query) Normalize() {
|
||||
if q.Page < 1 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize < 1 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
if q.PageSize > MaxPageSize {
|
||||
q.PageSize = MaxPageSize
|
||||
}
|
||||
}
|
||||
|
||||
func (q *Query) Offset() int {
|
||||
return (q.Page - 1) * q.PageSize
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package model
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
type SettingKey string
|
||||
|
||||
const (
|
||||
SettingKeyPublic SettingKey = "public"
|
||||
SettingKeyPrivate SettingKey = "private"
|
||||
)
|
||||
|
||||
// ModelChannel 模型渠道配置。
|
||||
type ModelChannel struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Name string `json:"name"`
|
||||
BaseURL string `json:"baseUrl"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Models []string `json:"models"`
|
||||
Weight int `json:"weight"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
// ModelCost 模型算力点配置。
|
||||
type ModelCost struct {
|
||||
Model string `json:"model"`
|
||||
Credits int `json:"credits"`
|
||||
}
|
||||
|
||||
// PublicModelChannelSetting 公开模型渠道配置。
|
||||
type PublicModelChannelSetting struct {
|
||||
AvailableModels []string `json:"availableModels"`
|
||||
ModelCosts []ModelCost `json:"modelCosts"`
|
||||
DefaultModel string `json:"defaultModel"`
|
||||
DefaultImageModel string `json:"defaultImageModel"`
|
||||
DefaultVideoModel string `json:"defaultVideoModel"`
|
||||
DefaultTextModel string `json:"defaultTextModel"`
|
||||
SystemPrompt string `json:"systemPrompt"`
|
||||
AllowCustomChannel *bool `json:"allowCustomChannel"`
|
||||
}
|
||||
|
||||
// PublicSetting 公开配置。
|
||||
type PublicSetting struct {
|
||||
ModelChannel PublicModelChannelSetting `json:"modelChannel"`
|
||||
Auth PublicAuthSetting `json:"auth"`
|
||||
}
|
||||
|
||||
type PublicAuthSetting struct {
|
||||
AllowRegister *bool `json:"allowRegister"`
|
||||
LinuxDo PublicLinuxDoAuthSetting `json:"linuxDo"`
|
||||
}
|
||||
|
||||
type PublicLinuxDoAuthSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// PrivateSetting 私有配置。
|
||||
type PrivateSetting struct {
|
||||
Channels []ModelChannel `json:"channels"`
|
||||
PromptSync PromptSyncSetting `json:"promptSync"`
|
||||
Auth PrivateAuthSetting `json:"auth"`
|
||||
}
|
||||
|
||||
// PromptSyncSetting 提示词定时同步配置。
|
||||
type PromptSyncSetting struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Cron string `json:"cron"`
|
||||
}
|
||||
|
||||
type PrivateAuthSetting struct {
|
||||
LinuxDo PrivateLinuxDoAuthSetting `json:"linuxDo"`
|
||||
}
|
||||
|
||||
type PrivateLinuxDoAuthSetting struct {
|
||||
ClientID string `json:"clientId"`
|
||||
ClientSecret string `json:"clientSecret"`
|
||||
}
|
||||
|
||||
// Setting 系统配置。
|
||||
type Setting struct {
|
||||
Key SettingKey `json:"key" gorm:"primaryKey"`
|
||||
Value json.RawMessage `json:"value" gorm:"serializer:json"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Settings 系统公开和私有配置。
|
||||
type Settings struct {
|
||||
Public PublicSetting `json:"public"`
|
||||
Private PrivateSetting `json:"private"`
|
||||
}
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
package model
|
||||
|
||||
type UserRole string
|
||||
|
||||
const (
|
||||
UserRoleGuest UserRole = "guest"
|
||||
UserRoleUser UserRole = "user"
|
||||
UserRoleAdmin UserRole = "admin"
|
||||
)
|
||||
|
||||
type UserStatus string
|
||||
|
||||
const (
|
||||
UserStatusActive UserStatus = "active"
|
||||
UserStatusBan UserStatus = "ban"
|
||||
)
|
||||
|
||||
// User 系统用户。
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
Username string `json:"username" gorm:"uniqueIndex"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Role UserRole `json:"role"`
|
||||
Credits int `json:"credits"`
|
||||
AffCode string `json:"affCode" gorm:"uniqueIndex"`
|
||||
AffCount int `json:"affCount"`
|
||||
InviterID string `json:"inviterId"`
|
||||
GithubID string `json:"githubId"`
|
||||
LinuxDoID string `json:"linuxDoId" gorm:"index"`
|
||||
WechatID string `json:"wechatId"`
|
||||
Status UserStatus `json:"status"`
|
||||
LastLoginAt string `json:"lastLoginAt"`
|
||||
Extra string `json:"extra" gorm:"type:text"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// UserList 用户分页结果。
|
||||
type UserList struct {
|
||||
Items []User `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// AuthUser 用户公开信息。
|
||||
type AuthUser struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AvatarURL string `json:"avatarUrl"`
|
||||
Role UserRole `json:"role"`
|
||||
Credits int `json:"credits"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// AuthSession 登录会话信息。
|
||||
type AuthSession struct {
|
||||
Token string `json:"token"`
|
||||
User AuthUser `json:"user"`
|
||||
}
|
||||
|
||||
func PublicUser(user User) AuthUser {
|
||||
return AuthUser{
|
||||
ID: user.ID,
|
||||
Username: user.Username,
|
||||
DisplayName: user.DisplayName,
|
||||
AvatarURL: user.AvatarURL,
|
||||
Role: user.Role,
|
||||
Credits: user.Credits,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type CreditLogType string
|
||||
|
||||
const (
|
||||
CreditLogTypeAdminAdjust CreditLogType = "admin_adjust"
|
||||
CreditLogTypeAIConsume CreditLogType = "ai_consume"
|
||||
CreditLogTypeAIRefund CreditLogType = "ai_refund"
|
||||
)
|
||||
|
||||
// CreditLog 用户算力点变更流水。
|
||||
type CreditLog struct {
|
||||
ID string `json:"id" gorm:"primaryKey"`
|
||||
UserID string `json:"userId" gorm:"index"`
|
||||
Type CreditLogType `json:"type"`
|
||||
Amount int `json:"amount"`
|
||||
Balance int `json:"balance"`
|
||||
RelatedID string `json:"relatedId"`
|
||||
Remark string `json:"remark"`
|
||||
Extra string `json:"extra" gorm:"type:text"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
type CreditLogList struct {
|
||||
Items []CreditLog `json:"items"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 运行期配置由容器启动时生成,禁止缓存,确保改环境变量后即时生效。
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# Infinite Canvas 画布节点插件
|
||||
|
||||
给画布扩展自定义节点。每个插件是一个**独立目录**,用 **TypeScript** 编写,自带 `package.json` / `build.mjs` / `src/index.tsx` / `dist/`,互不耦合,可单独构建、发布、升级。
|
||||
|
||||
内置节点只有文本、图片、视频、音频、生成配置、组六种;其余节点(Markdown、SVG、HTML、3D 全景、便利贴……)都是插件。
|
||||
|
||||
作者只写节点 UI 与逻辑,**类型、JSX、宿主 React、构建全部由 [`@infinite-canvas/plugin-sdk`](./sdk/README.md) 提供**,写 TSX 全程有代码提示;产物仍是宿主加载器现有契约的 ESM(React external、宿主单例)。
|
||||
|
||||
## 目录约定
|
||||
|
||||
```
|
||||
plugins/canvas/
|
||||
sdk/ # 插件 SDK(类型 + automatic JSX 运行时 + 构建助手)
|
||||
template/ # 起步模板:复制它开始写新插件
|
||||
markdown/ # 每个插件一个独立目录
|
||||
package.json
|
||||
build.mjs # 一行 buildPlugin,产物名取目录名 → dist/markdown.js
|
||||
tsconfig.json
|
||||
src/index.tsx # 插件源码(默认导出 definePlugin(...))
|
||||
README.md
|
||||
svg/ html/ panorama/ sticky-note/ ...
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cp -r plugins/canvas/template plugins/canvas/my-plugin
|
||||
cd plugins/canvas/my-plugin
|
||||
# 改 package.json 的 name;改 src/index.tsx 里的 id / nodes[].type
|
||||
npm install
|
||||
npm run dev # watch 构建,产物同步到 web/public/plugins/my-plugin.js
|
||||
npm run typecheck # tsc --noEmit,类型自检
|
||||
```
|
||||
|
||||
产物名取**目录名**,复制后记得把目录改成插件名。
|
||||
|
||||
## 构建 / 发布 / 升级
|
||||
|
||||
```bash
|
||||
cd plugins/canvas/<name>
|
||||
npm install
|
||||
npm run build # → dist/<name>.js,并同步到 web/public/plugins/<name>.js
|
||||
npm run dev # watch,改动自动构建并同步
|
||||
```
|
||||
|
||||
把 `dist/<name>.js` 托管到任意静态地址(CDN、GitHub Raw、对象存储),用户在画布「节点插件」管理器填该 URL 安装。升级时重新构建覆盖同一 URL,用户点「更新」即可。
|
||||
|
||||
## 官方插件注册表
|
||||
|
||||
本项目官方插件由 CI 集中构建后发布到孤儿分支 `plugins-dist`(**构建产物不进 git**),画布「节点插件」面板顶部的**官方插件**区经 jsDelivr 从该分支远程拉取并一键安装;第三方插件仍走下方「第三方插件」的 JS URL 安装。构建脚本与发布说明见 [`registry/`](./registry/README.md);清单地址可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖成自建来源。
|
||||
|
||||
## 本地开发
|
||||
|
||||
`npm run dev` 起 watch,产物会同步到 `web/public/plugins/<name>.js`。此后有两种方式在画布里用到它:
|
||||
|
||||
**方式一(推荐):自动发现。** 画布启动时会扫描 `web/public/plugins/` 下的插件,自动加入「节点插件」管理器列表,**默认关闭**;打开开关即启用。无需手动填 URL,启用时会按文件重新拉取,配合 watch 改完刷新即最新。
|
||||
|
||||
**方式二:`VITE_DEV_PLUGINS`。** 在 `web/.env.local` 声明(逗号分隔多个),这些插件每次刷新页面都**重新拉取并直接激活**(不缓存、不落库、无开关):
|
||||
|
||||
```env
|
||||
VITE_DEV_PLUGINS=/plugins/markdown.js,/plugins/svg.js
|
||||
```
|
||||
|
||||
再起画布 `web`(`npm run dev`)。流程即:改 `src/index.tsx` → watch 自动构建 → 刷新画布看到最新效果,无需反复安装。
|
||||
|
||||
## 用 SDK 写插件
|
||||
|
||||
默认导出 `definePlugin({...})`(对象形式,**无需再 `const { React } = runtime`**):
|
||||
|
||||
```tsx
|
||||
import { definePlugin, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
function Content({ ctx }: CanvasNodeContentProps) {
|
||||
const [n, setN] = useState(0);
|
||||
return (
|
||||
<button onMouseDown={(e) => e.stopPropagation()} onClick={() => setN((v) => v + 1)} style={{ color: ctx.theme.node.text }}>
|
||||
{ctx.node.title}: {n}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "my-plugin",
|
||||
name: "我的插件",
|
||||
version: "1.0.0",
|
||||
css: "…", // 可选:插件样式,自动注入/清理
|
||||
nodes: [ /* CanvasNodeDefinition[] */ ],
|
||||
setup(app) { return () => {}; }, // 可选,返回清理函数;app 含 injectCSS/emit/on
|
||||
});
|
||||
```
|
||||
|
||||
SDK 导出的 hooks(`useState/useEffect/useMemo/useRef/...`)运行时转发宿主 React,类型来自 `@types/react`。SDK 与依赖接入见 [`sdk/README.md`](./sdk/README.md)。
|
||||
|
||||
### CanvasNodeDefinition
|
||||
|
||||
```ts
|
||||
{
|
||||
type: string; // 建议 "<pluginId>:<name>",全局唯一
|
||||
title: string; // 创建菜单/默认标题
|
||||
icon: ReactNode; // emoji 字符串或任意 ReactNode
|
||||
description?: string;
|
||||
defaultSize: { width, height };
|
||||
defaultMetadata?: object; // 新建节点初始 metadata(文本内容放 content)
|
||||
minimapColor?: string;
|
||||
showInCreateMenu?: boolean; // 默认 true
|
||||
hasSourceHandle?: boolean; // 右侧输出连接点,默认 true
|
||||
keepAspectRatio?: (node) => boolean;
|
||||
resource?: (node) => { kind: "text"|"image"|"video"|"audio", text?, url? } | null; // 作为上游输入被消费时输出什么
|
||||
Content: ({ ctx }) => ReactNode; // 节点主体渲染
|
||||
Panel?: ({ ctx, onClose }) => ReactNode; // 可选:节点下方面板
|
||||
toolbar?: (ctx) => Array<{ id, title, label, icon, onClick, danger? }>; // 追加到 hover 工具栏
|
||||
onDoubleClick?: (ctx) => boolean; // 返回 true 表示已处理双击
|
||||
}
|
||||
```
|
||||
|
||||
### ctx:节点与画布交互接口
|
||||
|
||||
`Content` / `Panel` / `toolbar` 都会拿到 `ctx`(类型 `CanvasNodeContext`):
|
||||
|
||||
| 能力 | 说明 |
|
||||
| --- | --- |
|
||||
| `ctx.node` | 当前节点数据(含 `metadata.content` 等) |
|
||||
| `ctx.theme` / `ctx.scale` | 当前画布主题 token 与缩放,用来让 UI 跟随主题 |
|
||||
| `ctx.updateMetadata(patch)` | 更新自身 metadata(如保存内容) |
|
||||
| `ctx.updateNode(patch)` | 更新自身 title/width/height |
|
||||
| `ctx.getNode(id)` / `ctx.getNodes()` / `ctx.getConnections()` | 读画布 |
|
||||
| `ctx.getUpstream()` / `ctx.getDownstream()` | 取上/下游相连节点 |
|
||||
| `ctx.applyOps(ops)` | 用画布指令集增删节点/连线、选择、触发生成(见下) |
|
||||
| `ctx.emit(event, payload)` / `ctx.on(event, handler)` | 节点/插件间事件通信 |
|
||||
| `ctx.storage` | 插件私有持久化(按插件 id 命名空间) |
|
||||
|
||||
> `metadata` 的**内置字段**(content、status、model…)是强类型;插件写入的**自定义字段**读出为 `unknown`,按需 `as` 断言(参考 `sticky-note` 的 `pluginColor`)。
|
||||
|
||||
### 画布指令集(ctx.applyOps)
|
||||
|
||||
```ts
|
||||
ctx.applyOps([
|
||||
{ type: "add_node", id?, nodeType, title?, x?, y?, width?, height?, metadata? },
|
||||
{ type: "update_node", id, patch?, metadata? },
|
||||
{ type: "delete_node", id? | ids? },
|
||||
{ type: "connect_nodes", fromNodeId, toNodeId },
|
||||
{ type: "delete_connections", id? | ids? | all? },
|
||||
{ type: "select_nodes", ids },
|
||||
{ type: "set_viewport", viewport },
|
||||
{ type: "run_generation", nodeId, mode?, prompt? },
|
||||
]);
|
||||
```
|
||||
|
||||
## 重依赖 / 资源
|
||||
|
||||
- **重依赖**(three.js、marked 等):不要打进 bundle,运行时 `await import("https://esm.sh/...")` 动态加载(esbuild 自动 external);在 `src/env.d.ts` 声明该模块以通过 tsc。参考 `panorama/`、`markdown/`。
|
||||
- **CSS**:写独立 `.css`,`import css from "./styles.css"` 拿到字符串(esbuild `text` loader),放到 `css` 字段自动注入/清理;`src/env.d.ts` 声明 `*.css`。参考 `markdown/`。
|
||||
- **HTML**:HTML 节点把 HTML 字符串塞进 sandbox iframe 的 `srcDoc`,自带 `<style>`,不需要插件级 CSS。参考 `html/`。
|
||||
|
||||
## 兼容说明
|
||||
|
||||
加载器仍接受**默认导出为工厂函数** `(runtime) => CanvasPlugin`(用 `runtime.React`)或**普通对象**;老的 JS 插件无需改动即可运行。SDK 的 automatic JSX 让新插件走对象形式,更简洁。
|
||||
|
||||
## 注意
|
||||
|
||||
- 插件代码会在画布页面内**直接执行**,可访问浏览器本地数据(含 AI API Key)。发布前请自审,用户也只应安装可信来源。
|
||||
- 交互控件记得 `onMouseDown={(e) => e.stopPropagation()}`(避免触发节点拖拽),滚动区域加 `onWheel={(e) => e.stopPropagation()}` 与容器 `data-canvas-no-zoom`(避免被画布缩放拦截)。
|
||||
@@ -0,0 +1,17 @@
|
||||
# HTML 节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:用沙箱 iframe 渲染 HTML。源码里的 `{{input}}` 会被替换为上游文本节点内容。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/html.js,并同步到 web/public/plugins/html.js
|
||||
npm run dev # watch
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/html.js`(或托管后的公网 URL)。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
Generated
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-html",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas HTML 节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// HTML 节点:沙箱 iframe 渲染 HTML,{{input}} 会替换为上游文本节点内容。
|
||||
// 交互:预览态 iframe 默认 pointer-events:none —— 鼠标事件穿透到宿主节点体,
|
||||
// 因此点节点任意位置都能拖动,无需在节点上加任何标题栏/按钮。
|
||||
// 「编辑/预览」与「交互」开关都放在节点外的悬浮工具条(toolbar 扩展点),状态存 metadata。
|
||||
import { definePlugin, useMemo, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// 源码编辑器行高/字号,行号槽与文本域必须完全一致才能对齐
|
||||
const EDITOR_FONT = 12;
|
||||
const EDITOR_LINE = 20;
|
||||
|
||||
function HtmlEditor({ ctx, value }: { ctx: CanvasNodeContentProps["ctx"]; value: string }) {
|
||||
const gutterRef = useRef<HTMLDivElement>(null);
|
||||
// 行数:按换行统计,至少 1 行;value 变化时重算
|
||||
const lineCount = useMemo(() => Math.max(1, value.split("\n").length), [value]);
|
||||
const [scrollTop, setScrollTop] = useState(0);
|
||||
|
||||
const codeStyle = { fontFamily: "monospace", fontSize: EDITOR_FONT, lineHeight: `${EDITOR_LINE}px`, boxSizing: "border-box" } as const;
|
||||
|
||||
return (
|
||||
<div data-canvas-no-zoom style={{ height: "100%", width: "100%", display: "flex", overflow: "hidden", borderRadius: 16, background: ctx.theme.node.fill }} onMouseDown={(e) => e.stopPropagation()}>
|
||||
{/* 行号槽:跟随文本域滚动,不可单独滚动 */}
|
||||
<div
|
||||
ref={gutterRef}
|
||||
aria-hidden
|
||||
style={{
|
||||
...codeStyle,
|
||||
flex: "0 0 auto",
|
||||
padding: "16px 8px 16px 12px",
|
||||
textAlign: "right",
|
||||
color: ctx.theme.node.placeholder,
|
||||
background: `${ctx.theme.toolbar.panel}66`,
|
||||
borderRight: `1px solid ${ctx.theme.node.stroke}`,
|
||||
overflow: "hidden",
|
||||
userSelect: "none",
|
||||
whiteSpace: "pre",
|
||||
}}
|
||||
>
|
||||
{/* 用负 margin 让整列跟随 scrollTop 平移,和 textarea 同步 */}
|
||||
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
||||
{Array.from({ length: lineCount }, (_, i) => (
|
||||
<div key={i}>{i + 1}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="<div>Hello, {{input}}</div>"
|
||||
spellCheck={false}
|
||||
wrap="off"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ ...codeStyle, flex: "1 1 auto", minWidth: 0, height: "100%", resize: "none", background: "transparent", padding: "16px 16px 16px 12px", outline: "none", border: "none", color: ctx.theme.node.text, whiteSpace: "pre", overflow: "auto" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HtmlContent({ ctx }: CanvasNodeContentProps) {
|
||||
const value = ctx.node.metadata?.content || "";
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
const upstreamText = useMemo(
|
||||
() =>
|
||||
ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
[ctx],
|
||||
);
|
||||
const html = value.replace(/\{\{\s*input\s*\}\}/g, upstreamText);
|
||||
|
||||
if (editing) {
|
||||
return <HtmlEditor ctx={ctx} value={value} />;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 8, color: ctx.theme.node.placeholder }}>
|
||||
<span style={{ fontSize: 26 }}>{"</>"}</span>
|
||||
<span style={{ fontSize: 13 }}>选中节点,点上方工具条的 ✎ 编辑 HTML</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 预览态:iframe 的鼠标交互由宿主「交互 ⇄ 移动」开关统一控制(见 interactionToggle),
|
||||
// 这里无需再手动做 pointer-events 穿透。data-canvas-no-zoom 保证交互时滚动作用于页面而非缩放画布。
|
||||
return (
|
||||
<div data-canvas-no-zoom style={{ position: "relative", height: "100%", width: "100%" }}>
|
||||
<iframe
|
||||
title="html-preview"
|
||||
sandbox="allow-scripts allow-forms"
|
||||
srcDoc={html}
|
||||
style={{ height: "100%", width: "100%", border: 0, borderRadius: 16, background: "#fff", display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "html",
|
||||
name: "HTML 节点",
|
||||
version: "1.2.0",
|
||||
description: "沙箱 iframe 渲染 HTML,支持 {{input}} 注入上游文本",
|
||||
nodes: [
|
||||
{
|
||||
type: "html:render",
|
||||
title: "HTML",
|
||||
icon: "🌐",
|
||||
description: "沙箱渲染 HTML",
|
||||
defaultSize: { width: 420, height: 320 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#ec4899",
|
||||
hidePanel: true, // 纯展示型节点:点击/新建不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互(编辑器始终可操作)并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
Content: HtmlContent,
|
||||
// 仅保留「编辑/预览」开关;交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "html-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 HTML 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
# Markdown 节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:在画布里编辑与渲染 Markdown。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/markdown.js,并同步到 web/public/plugins/markdown.js
|
||||
npm run dev # watch,改动自动构建
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/markdown.js`(或托管后的公网 URL)。
|
||||
|
||||
## 本地开发
|
||||
|
||||
`npm run dev` 起 watch,在 `web/.env.local` 加 `VITE_DEV_PLUGINS=/plugins/markdown.js`,起画布后改 `src/index.jsx` 刷新页面即生效,无需反复安装。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
|
||||
> 本插件演示了独立 CSS 文件用法:`src/styles.css` 经 esbuild `text` loader 打进 bundle,通过插件 `css` 字段自动注入/清理。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-markdown",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas Markdown 节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
// esbuild 以 text loader 把 .css 打成字符串
|
||||
declare module "*.css" {
|
||||
const css: string;
|
||||
export default css;
|
||||
}
|
||||
|
||||
// 重依赖从 CDN 动态加载,不打进 bundle;此处声明以便 TS 提示
|
||||
declare module "https://esm.sh/marked@14" {
|
||||
export const marked: { parse: (src: string) => string };
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Markdown 节点:编辑与渲染 Markdown。marked 从 CDN 按需加载,不打进插件体积。
|
||||
// 移动/交互:走宿主统一的「交互 ⇄ 移动」开关(interactionToggle)——默认「移动」态整块可拖,
|
||||
// 切「交互」态可滚动/选择;编辑态强制可交互。
|
||||
// 防闪烁:解析结果按源码模块级缓存,且只在 HTML 真正变化时写入 DOM——
|
||||
// 画布任何重渲染都不会重新解析或重载 Markdown 里的图片。
|
||||
// styles.css 由 esbuild 以 text 方式打进 bundle,通过 plugin.css 自动注入。
|
||||
import { definePlugin, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
import css from "./styles.css";
|
||||
|
||||
type Marked = { parse: (src: string) => string };
|
||||
|
||||
let marked: Marked | undefined; // 加载完成后的 marked 实例,模块级缓存,后续同步渲染
|
||||
let markedPromise: Promise<Marked> | undefined;
|
||||
function loadMarked(): Promise<Marked> {
|
||||
if (marked) return Promise.resolve(marked);
|
||||
if (!markedPromise) markedPromise = import("https://esm.sh/marked@14").then((mod: { marked: Marked }) => (marked = mod.marked));
|
||||
return markedPromise;
|
||||
}
|
||||
|
||||
const PLACEHOLDER = "*选中节点,点上方工具条的 ✎ 编辑 Markdown*";
|
||||
|
||||
// 解析结果按源码缓存(模块级):同一段 Markdown 只解析一次,重复渲染/重挂载都命中缓存,
|
||||
// 返回的是同一个字符串引用,配合下方「值不变不写 DOM」彻底避免图片被重新请求。
|
||||
const htmlCache = new Map<string, string>();
|
||||
function renderMarkdown(source: string): string {
|
||||
if (!marked) return "";
|
||||
const key = source || PLACEHOLDER;
|
||||
let out = htmlCache.get(key);
|
||||
if (out === undefined) {
|
||||
out = marked.parse(key);
|
||||
htmlCache.set(key, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 预览态:用 ref 手动写 innerHTML,且仅在 html 真正变化时写入。
|
||||
// 这样宿主的任何重渲染(点击、移动视角、选中态变化等)都不会触碰已渲染的 DOM,图片不会重新加载。
|
||||
function MarkdownPreview({ ctx }: CanvasNodeContentProps) {
|
||||
const [, force] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const lastHtml = useRef<string | null>(null);
|
||||
|
||||
// marked 未就绪时按需加载,加载完触发一次重渲染填充内容
|
||||
useEffect(() => {
|
||||
if (marked) return;
|
||||
let alive = true;
|
||||
loadMarked().then(() => alive && force((n) => n + 1));
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const source = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
const html = renderMarkdown(source);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || lastHtml.current === html) return; // 内容没变:不动 DOM,已加载的图片保持原样
|
||||
el.innerHTML = html;
|
||||
lastHtml.current = html;
|
||||
}, [html]);
|
||||
|
||||
return <div ref={ref} className="cnv-md" data-canvas-no-zoom onWheel={(e) => e.stopPropagation()} style={{ height: "100%", width: "100%", color: ctx.theme.node.text }} />;
|
||||
}
|
||||
|
||||
function MarkdownEditor({ ctx }: CanvasNodeContentProps) {
|
||||
const value = (ctx.node.metadata?.content as string | undefined) || "";
|
||||
return (
|
||||
<textarea
|
||||
autoFocus
|
||||
value={value}
|
||||
placeholder="# 输入 Markdown"
|
||||
onChange={(e) => ctx.updateMetadata({ content: e.target.value })}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
style={{ height: "100%", width: "100%", resize: "none", background: ctx.theme.node.fill, borderRadius: 16, boxSizing: "border-box", padding: 16, fontFamily: "monospace", fontSize: 14, outline: "none", border: "none", color: ctx.theme.node.text }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MarkdownContent({ ctx }: CanvasNodeContentProps) {
|
||||
return ctx.node.metadata?.editing ? <MarkdownEditor ctx={ctx} /> : <MarkdownPreview ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "markdown",
|
||||
name: "Markdown 节点",
|
||||
version: "1.1.0",
|
||||
description: "在画布中编辑与渲染 Markdown",
|
||||
css,
|
||||
nodes: [
|
||||
{
|
||||
type: "markdown:doc",
|
||||
title: "Markdown",
|
||||
icon: "📝",
|
||||
description: "编辑与渲染 Markdown",
|
||||
defaultSize: { width: 360, height: 300 },
|
||||
defaultMetadata: { content: "" },
|
||||
minimapColor: "#6366f1",
|
||||
hidePanel: true, // 纯展示/编辑节点:不弹出下方生图面板
|
||||
// 宿主统一提供「交互 ⇄ 移动」开关;编辑态强制可交互并隐藏该开关
|
||||
interactionToggle: true,
|
||||
forceInteractive: (node) => Boolean(node.metadata?.editing),
|
||||
resource: (node) => ({ kind: "text", text: node.metadata?.content }),
|
||||
Content: MarkdownContent,
|
||||
// 仅保留「编辑/预览」开关(状态存 metadata.editing);交互/移动 由宿主自动注入
|
||||
toolbar: (ctx) => {
|
||||
const editing = Boolean(ctx.node.metadata?.editing);
|
||||
return [
|
||||
{
|
||||
id: "md-toggle-edit",
|
||||
title: editing ? "预览渲染结果" : "编辑 Markdown 源码",
|
||||
label: editing ? "预览" : "编辑",
|
||||
icon: editing ? "👁" : "✎",
|
||||
active: editing,
|
||||
onClick: () => ctx.updateMetadata({ editing: !editing }),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
.cnv-md {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.cnv-md h1,
|
||||
.cnv-md h2,
|
||||
.cnv-md h3 {
|
||||
margin: 0.6em 0 0.3em;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.cnv-md h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.cnv-md h2 {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.cnv-md p {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.cnv-md a {
|
||||
color: #6366f1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.cnv-md code {
|
||||
padding: 0.1em 0.35em;
|
||||
border-radius: 4px;
|
||||
background: rgba(120, 120, 120, 0.16);
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.cnv-md pre {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(120, 120, 120, 0.14);
|
||||
overflow: auto;
|
||||
}
|
||||
.cnv-md pre code {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.cnv-md ul,
|
||||
.cnv-md ol {
|
||||
padding-left: 1.4em;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
.cnv-md blockquote {
|
||||
margin: 0.5em 0;
|
||||
padding-left: 0.8em;
|
||||
border-left: 3px solid rgba(120, 120, 120, 0.4);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cnv-md img {
|
||||
max-width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# 3D 全景节点插件
|
||||
|
||||
Infinite Canvas 画布节点插件:查看 360° 等距柱状(equirectangular)全景图,可拖拽旋转。可从上游图片节点自动取图。three.js 运行时从 CDN 按需加载,不打进插件体积。
|
||||
|
||||
## 构建
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build # 产物 dist/panorama.js,并同步到 web/public/plugins/panorama.js
|
||||
npm run dev # watch
|
||||
```
|
||||
|
||||
## 安装
|
||||
|
||||
画布 → 左上菜单「节点插件」→ 安装 URL 填 `/plugins/panorama.js`(或托管后的公网 URL)。连接一个全景图片节点到本节点即可查看。
|
||||
|
||||
插件契约见 `plugins/canvas/README.md`。
|
||||
@@ -0,0 +1,3 @@
|
||||
import { buildPlugin } from "@infinite-canvas/plugin-sdk/build";
|
||||
|
||||
await buildPlugin(import.meta.url);
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
},
|
||||
"../sdk": {
|
||||
"name": "@infinite-canvas/plugin-sdk",
|
||||
"version": "0.1.0",
|
||||
"dev": true,
|
||||
"devDependencies": {
|
||||
"@types/react": "19.1.12",
|
||||
"esbuild": "^0.25.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@infinite-canvas/plugin-sdk": {
|
||||
"resolved": "../sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "canvas-plugin-panorama",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Infinite Canvas 3D 全景节点插件",
|
||||
"scripts": {
|
||||
"build": "node build.mjs",
|
||||
"dev": "node build.mjs --watch",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@infinite-canvas/plugin-sdk": "file:../sdk",
|
||||
"@types/react": "19.1.12",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
// three 从 CDN 动态加载,不打进 bundle;声明为宽松类型以便通过 tsc
|
||||
declare module "https://esm.sh/three@0.180.0";
|
||||
@@ -0,0 +1,311 @@
|
||||
// 3D 全景节点:等距柱状(equirectangular)360° 全景查看器。
|
||||
// 三种取图方式:① 从上游图片节点自动取图 ② 本地上传 ③ AI 生成(复用宿主内置生成面板)。
|
||||
// three.js 从 CDN 按需加载,不打进 bundle。AI 生成通过 useBuiltinPanel 声明,结果写回本节点。
|
||||
import { definePlugin, useCallback, useEffect, useRef, useState } from "@infinite-canvas/plugin-sdk";
|
||||
import type { CanvasNodeContentProps, CanvasNodeContext } from "@infinite-canvas/plugin-sdk";
|
||||
|
||||
// three 从 CDN 动态加载(env.d.ts 里声明为 any),不打进 bundle
|
||||
let threePromise: Promise<any> | undefined;
|
||||
function loadThree(): Promise<any> {
|
||||
if (!threePromise) threePromise = import("https://esm.sh/three@0.180.0");
|
||||
return threePromise;
|
||||
}
|
||||
|
||||
// AI 生成的固定前置提示词:约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||
// 用户只需描述场景内容,这段会自动拼在前面,保证 2:1、无缝、无畸变文字等。
|
||||
const PANORAMA_SYSTEM_PROMPT =
|
||||
"A seamless 360-degree equirectangular panorama, 2:1 aspect ratio, full spherical VR photo, " +
|
||||
"horizontally wrapping seamlessly at the left and right edges, no visible seam, no distortion artifacts, " +
|
||||
"even horizon, no text, no watermark. Scene: ";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 全景查看器:球体内壁贴等距柱状纹理,指针拖动转视角、滚轮缩放 fov。
|
||||
// 自适应容器尺寸(ResizeObserver);超过 GPU 上限的大图先降采样,避免加载失败。
|
||||
// ---------------------------------------------------------------------------
|
||||
function PanoramaViewer({ src, ctx }: { src: string; ctx: CanvasNodeContext }) {
|
||||
const mountRef = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => {
|
||||
const mount = mountRef.current;
|
||||
if (!mount || !src) return;
|
||||
let disposed = false;
|
||||
let frame = 0;
|
||||
let renderer: any = null;
|
||||
let geometry: any = null;
|
||||
let material: any = null;
|
||||
let texture: any = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let cleanupEvents = () => {};
|
||||
|
||||
setStatus("loading");
|
||||
|
||||
loadThree()
|
||||
.then((THREE: any) => {
|
||||
if (disposed || !mountRef.current) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(70, 1, 1, 1100);
|
||||
const target = new THREE.Vector3();
|
||||
|
||||
renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||
renderer.setClearColor(0x000000, 1);
|
||||
const canvas: HTMLCanvasElement = renderer.domElement;
|
||||
canvas.style.width = "100%";
|
||||
canvas.style.height = "100%";
|
||||
canvas.style.display = "block";
|
||||
canvas.style.cursor = "grab";
|
||||
mount.appendChild(canvas);
|
||||
const maxTextureSide = Math.min(renderer.capabilities.maxTextureSize || 4096, 4096);
|
||||
|
||||
geometry = new THREE.SphereGeometry(500, 96, 64);
|
||||
geometry.scale(-1, 1, 1); // 翻转法线,从球心向内看
|
||||
material = new THREE.MeshBasicMaterial({ color: 0xffffff });
|
||||
scene.add(new THREE.Mesh(geometry, material));
|
||||
|
||||
// 视角状态
|
||||
let lon = 0;
|
||||
let lat = 0;
|
||||
let fov = 70;
|
||||
let dragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startLon = 0;
|
||||
let startLat = 0;
|
||||
|
||||
const onDown = (event: PointerEvent) => {
|
||||
dragging = true;
|
||||
startX = event.clientX;
|
||||
startY = event.clientY;
|
||||
startLon = lon;
|
||||
startLat = lat;
|
||||
canvas.style.cursor = "grabbing";
|
||||
canvas.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
const onMove = (event: PointerEvent) => {
|
||||
if (!dragging) return;
|
||||
lon = startLon - (event.clientX - startX) * 0.12;
|
||||
lat = Math.max(-84, Math.min(84, startLat + (event.clientY - startY) * 0.12));
|
||||
};
|
||||
const onUp = () => {
|
||||
dragging = false;
|
||||
canvas.style.cursor = "grab";
|
||||
};
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
fov = Math.max(38, Math.min(86, fov + event.deltaY * 0.035));
|
||||
};
|
||||
canvas.addEventListener("pointerdown", onDown);
|
||||
canvas.addEventListener("pointermove", onMove);
|
||||
canvas.addEventListener("pointerup", onUp);
|
||||
canvas.addEventListener("pointercancel", onUp);
|
||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
||||
cleanupEvents = () => {
|
||||
canvas.removeEventListener("pointerdown", onDown);
|
||||
canvas.removeEventListener("pointermove", onMove);
|
||||
canvas.removeEventListener("pointerup", onUp);
|
||||
canvas.removeEventListener("pointercancel", onUp);
|
||||
canvas.removeEventListener("wheel", onWheel);
|
||||
};
|
||||
|
||||
const resize = () => {
|
||||
if (!renderer || !mountRef.current) return;
|
||||
const width = Math.max(1, mountRef.current.clientWidth);
|
||||
const height = Math.max(1, mountRef.current.clientHeight);
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
};
|
||||
resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(mount);
|
||||
resize();
|
||||
|
||||
const render = () => {
|
||||
if (disposed || !renderer) return;
|
||||
if (!dragging) lon += 0.02; // 缓慢自转
|
||||
camera.fov = fov;
|
||||
camera.updateProjectionMatrix();
|
||||
const phi = THREE.MathUtils.degToRad(90 - lat);
|
||||
const theta = THREE.MathUtils.degToRad(lon);
|
||||
target.set(500 * Math.sin(phi) * Math.cos(theta), 500 * Math.cos(phi), 500 * Math.sin(phi) * Math.sin(theta));
|
||||
camera.lookAt(target);
|
||||
renderer.render(scene, camera);
|
||||
frame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
|
||||
// 载入纹理:大图降采样到 GPU 上限,避免超限导致黑屏/失败
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
image.onload = () => {
|
||||
if (disposed || !material) return;
|
||||
try {
|
||||
const w = image.naturalWidth || image.width;
|
||||
const h = image.naturalHeight || image.height;
|
||||
const scale = Math.min(1, maxTextureSide / w, maxTextureSide / h);
|
||||
let source: HTMLImageElement | HTMLCanvasElement = image;
|
||||
if (scale < 1) {
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.max(1, Math.round(w * scale));
|
||||
c.height = Math.max(1, Math.round(h * scale));
|
||||
c.getContext("2d")?.drawImage(image, 0, 0, c.width, c.height);
|
||||
source = c;
|
||||
}
|
||||
texture = source instanceof HTMLCanvasElement ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
|
||||
texture.colorSpace = THREE.SRGBColorSpace;
|
||||
texture.minFilter = THREE.LinearFilter;
|
||||
texture.needsUpdate = true;
|
||||
material.map = texture;
|
||||
material.needsUpdate = true;
|
||||
setStatus("ready");
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
};
|
||||
image.onerror = () => !disposed && setStatus("error");
|
||||
image.src = src;
|
||||
})
|
||||
.catch(() => !disposed && setStatus("error"));
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanupEvents();
|
||||
resizeObserver?.disconnect();
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
texture?.dispose?.();
|
||||
material?.dispose?.();
|
||||
geometry?.dispose?.();
|
||||
if (renderer) {
|
||||
renderer.domElement?.remove?.();
|
||||
renderer.dispose?.();
|
||||
renderer.forceContextLoss?.();
|
||||
}
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", height: "100%", width: "100%", overflow: "hidden", borderRadius: 16, background: "#000" }}>
|
||||
<div ref={mountRef} data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()} style={{ position: "absolute", inset: 0 }} />
|
||||
{status !== "ready" ? (
|
||||
<div style={{ position: "absolute", inset: 0, display: "grid", placeItems: "center", pointerEvents: "none", color: ctx.theme.node.placeholder, fontSize: 13, background: status === "error" ? "rgba(0,0,0,0.6)" : `linear-gradient(135deg, ${ctx.theme.node.fill}, #000)` }}>
|
||||
{status === "error" ? "全景图读取失败,请换 2:1 的 JPG/PNG 全景图" : "正在加载全景…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 空态:两个明确按钮——「上传全景图」和「AI 生成」(打开下方内置生成面板)。
|
||||
// 整块不可点,避免误触;拖动节点照常。
|
||||
// ---------------------------------------------------------------------------
|
||||
function PanoramaEmpty({ ctx }: { ctx: CanvasNodeContext }) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 本地上传:读为 dataURL 写入自身 metadata.content
|
||||
const onPick = useCallback(
|
||||
(file: File | undefined) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => ctx.updateMetadata({ content: String(reader.result || "") });
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
[ctx],
|
||||
);
|
||||
|
||||
const t = ctx.theme;
|
||||
const baseBtn = {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
cursor: "pointer",
|
||||
borderRadius: 999,
|
||||
padding: "9px 18px",
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${t.node.stroke}`,
|
||||
background: t.node.fill,
|
||||
color: t.node.text,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div style={{ height: "100%", width: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", gap: 14, padding: 20, borderRadius: 16, boxSizing: "border-box", background: t.node.fill }}>
|
||||
<span style={{ fontSize: 34 }}>🌐</span>
|
||||
<div data-canvas-no-zoom onMouseDown={(e) => e.stopPropagation()} style={{ display: "flex", gap: 10 }}>
|
||||
<input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={(e) => onPick(e.target.files?.[0])} />
|
||||
<button type="button" style={baseBtn} onClick={() => fileRef.current?.click()}>
|
||||
🖼 上传全景图
|
||||
</button>
|
||||
<button type="button" style={{ ...baseBtn, border: "none", background: t.toolbar.activeBg, color: t.toolbar.activeText }} onClick={() => ctx.openPanel()}>
|
||||
✨ AI 生成
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: t.node.placeholder, textAlign: "center" }}>支持 2:1 等距柱状全景图</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PanoramaContent({ ctx }: CanvasNodeContentProps) {
|
||||
const upstreamImage = ctx
|
||||
.getUpstream()
|
||||
.map((node) => node.metadata?.content)
|
||||
.find((content): content is string => typeof content === "string" && Boolean(content));
|
||||
const source = (typeof ctx.node.metadata?.content === "string" ? ctx.node.metadata.content : "") || upstreamImage || "";
|
||||
|
||||
if (!source) return <PanoramaEmpty ctx={ctx} />;
|
||||
return <PanoramaViewer src={source} ctx={ctx} />;
|
||||
}
|
||||
|
||||
export default definePlugin({
|
||||
id: "panorama",
|
||||
name: "3D 全景节点",
|
||||
version: "1.1.0",
|
||||
description: "查看 360° 等距柱状全景图,支持上传与 AI 生成,可从上游图片节点取图",
|
||||
nodes: [
|
||||
{
|
||||
type: "panorama:viewer",
|
||||
title: "3D 全景",
|
||||
icon: "🌐",
|
||||
description: "360° 全景查看器(上传 / AI 生成)",
|
||||
defaultSize: { width: 480, height: 300 },
|
||||
defaultMetadata: {},
|
||||
minimapColor: "#0ea5e9",
|
||||
// 宿主自动提供「交互 ⇄ 移动」开关:默认移动(拖动节点),切到交互后可转全景视角
|
||||
interactionToggle: true,
|
||||
// 复用宿主内置生成面板(模型选择/设置/提示词库完全一致),生成结果写回本节点。
|
||||
// 前缀约束模型产出可用于球面贴图的 equirectangular 全景图。
|
||||
useBuiltinPanel: { mode: "image", promptPrefix: PANORAMA_SYSTEM_PROMPT, writeBackToSelf: true },
|
||||
// 作为上游被消费时,输出自身全景图,供下游节点引用
|
||||
resource: (node) => {
|
||||
const content = node.metadata?.content;
|
||||
return typeof content === "string" && content ? { kind: "image", url: content } : null;
|
||||
},
|
||||
Content: PanoramaContent,
|
||||
// 工具条始终提供「AI 生成」(打开下方内置面板);有图时额外提供「换图」
|
||||
toolbar: (ctx) => {
|
||||
const hasOwnContent = typeof ctx.node.metadata?.content === "string" && Boolean(ctx.node.metadata?.content);
|
||||
return [
|
||||
{
|
||||
id: "panorama-generate",
|
||||
title: "用 AI 生成全景图(打开下方面板)",
|
||||
label: "AI 生成",
|
||||
icon: "✨",
|
||||
onClick: () => ctx.openPanel(),
|
||||
},
|
||||
...(hasOwnContent
|
||||
? [
|
||||
{
|
||||
id: "panorama-reset",
|
||||
title: "清空当前全景图,重新上传",
|
||||
label: "换图",
|
||||
icon: "🔄",
|
||||
onClick: () => ctx.updateMetadata({ content: "" }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@infinite-canvas/plugin-sdk",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"types": []
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# 官方插件注册表(集中构建 + 远程发布)
|
||||
|
||||
本目录**只放构建脚本**,不放构建产物。官方插件由 CI 构建后发布到孤儿分支 `plugins-dist`,画布经 jsDelivr 从该分支远程拉取并一键安装。第三方插件不进本流程,由用户自行填 JS URL 安装。
|
||||
|
||||
```
|
||||
registry/
|
||||
package.json # 构建依赖(esbuild + SDK)
|
||||
build.mjs # 一次进程构建所有官方插件 → dist/(gitignore)+ 生成清单
|
||||
dist/ # 构建产物(gitignore,不提交;CI 发布到 plugins-dist 分支)
|
||||
```
|
||||
|
||||
**产物不进 git**:`dist/` 与 `node_modules/` 均被 `.gitignore` 覆盖。`main` 分支只有源码与脚本。
|
||||
|
||||
## 发布流程(CI 自动)
|
||||
|
||||
`.github/workflows/publish-plugins.yml` 在**打版本 tag(`v*`)**或手动触发(`workflow_dispatch`)时,与 GitHub Pages 发布一起跑:
|
||||
|
||||
1. `npm install && npm run build` → 在 `dist/` 产出各 `<id>.js` 与 `official-plugins.json`;
|
||||
2. 把 `dist/` 强推到孤儿分支 **`plugins-dist`**(仅含产物,force-push 覆盖)。
|
||||
|
||||
前端默认从下面地址读取(可用 `VITE_PLUGIN_REGISTRY_URL` 覆盖):
|
||||
|
||||
```
|
||||
https://cdn.jsdelivr.net/gh/basketikun/infinite-canvas@plugins-dist/official-plugins.json
|
||||
```
|
||||
|
||||
清单里每条的 `entry`(相对文件名)由前端解析成与清单同目录的绝对 URL,再走既有 URL 安装流程。jsDelivr 对分支有缓存(约数小时),需要立即生效可对该分支目录做 purge。
|
||||
|
||||
## 新增 / 更新官方插件
|
||||
|
||||
- 改完某官方插件源码后,在 `build.mjs` 的 `OFFICIAL` 里保持登记(新增插件在此加一条),提交到 `main`;
|
||||
- 下次打版本 tag(或手动 `workflow_dispatch`)时,CI 自动重新构建并发布到 `plugins-dist`。
|
||||
|
||||
## 本地自测官方面板
|
||||
|
||||
```bash
|
||||
cd plugins/canvas/registry && npm install && npm run build # 产出 dist/
|
||||
# 用任意静态服务器伺服 dist/,把 VITE_PLUGIN_REGISTRY_URL 指向其 official-plugins.json
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user