mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 --frozen-lockfile
|
||||
|
||||
- name: Build
|
||||
working-directory: web
|
||||
env:
|
||||
VITE_BASE: /${{ github.event.repository.name }}/
|
||||
run: bun run build
|
||||
|
||||
- name: Add SPA fallback
|
||||
run: cp web/dist/index.html web/dist/404.html
|
||||
|
||||
- uses: actions/configure-pages@v5
|
||||
|
||||
- uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: web/dist
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -8,3 +8,4 @@ data/
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.idea
|
||||
web/dist
|
||||
@@ -8,7 +8,7 @@
|
||||
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
|
||||
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
|
||||
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
|
||||
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
|
||||
- 项目尚未上线,不需要兼容旧数据;本地存储结构调整时直接按新设计修改,不写旧字段兼容或数据迁移兜底,除非用户明确要求。
|
||||
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
|
||||
- 不要改无关文件,不要顺手重构。
|
||||
- 如果工作区已有用户改动,不要回滚,不要覆盖;只在必要范围内追加修改。
|
||||
@@ -19,28 +19,18 @@
|
||||
- 补充时写成明确、可执行的规则,避免只写模糊描述。
|
||||
- 新规则应放到最相关的章节;找不到合适章节时放到“项目注意事项”。
|
||||
|
||||
## 后端规范
|
||||
|
||||
- 后端使用 Go + Gin + GORM。
|
||||
- `handler/` 只处理 HTTP 入参、调用 service、返回 `OK` / `Fail`。
|
||||
- `service/` 放业务逻辑、默认值、校验、时间、ID、鉴权等处理。
|
||||
- `repository/` 只做数据库访问和 GORM 查询。
|
||||
- `model/` 只定义数据结构、枚举和简单模型方法。
|
||||
- 列表接口优先沿用 `model.Query`、`Normalize`、分页和标签筛选方式。
|
||||
- 业务接口保持 `{ code, data, msg }` 的响应结构。
|
||||
- 新增数据表时同步更新 `docs/backend-database.md`。
|
||||
|
||||
## 前端规范
|
||||
|
||||
- 前端使用 Next.js App Router、React、TypeScript、Ant Design、Tailwind、Zustand。
|
||||
- 前端使用 Vite、React、React Router、TypeScript、Ant Design、Tailwind、Zustand。
|
||||
- 编写 Ant Design 相关代码时,参考 https://ant.design/llms-full.txt 理解组件 API、示例和设计规范,并优先结合项目当前 antd 版本与既有写法。
|
||||
- API 请求统一放在 `web/src/services/api/`。
|
||||
- 外部服务请求统一放在 `web/src/services/api/`,由浏览器前端直连,不假设存在项目后端。
|
||||
- 全局或跨页面状态优先放在 `web/src/stores/`。
|
||||
- 已经放在全局 store 或全局 hook 中的状态/动作,组件需要时直接使用对应 store/hook,不要为了“纯组件”层层透传 props;避免一个组件传递过多参数。
|
||||
- 全局组件、全局常量、全局配置等全局性质的内容不要作为 props 或参数层层传递;哪里需要就在哪里直接从对应全局入口获取。
|
||||
- 多个页面重复出现的 UI 副作用动作,例如复制文本并提示、下载并提示、统一确认弹窗,优先抽成 `web/src/hooks/` 下的全局 hook;不要放进 store,除非它确实是需要共享/订阅的状态。
|
||||
- 画布相关状态和组件放在 `web/src/app/(user)/canvas/` 内部。
|
||||
- 页面里只有一个主业务组件时直接写在 `page.tsx`,不要单独拆 `Manager` 组件再传一堆 props。
|
||||
- 路由页面放在 `web/src/pages/`,页面布局放在 `web/src/layouts/`,路由配置放在 `web/src/router.tsx`。
|
||||
- 画布页面放在 `web/src/pages/canvas/`,画布组件放在 `web/src/components/canvas/`,画布状态放在 `web/src/stores/canvas/`,画布工具函数放在 `web/src/lib/canvas/`。
|
||||
- 页面按目录组织,例如 `web/src/pages/image/index.tsx`;页面里只有一个主业务组件时直接写在对应页面入口中,不要单独拆 `Manager` 组件再传一堆 props。
|
||||
- 不要新增只做简单转发的组件,例如只 `return <X>{children}</X>` 或只换个名字透传 props;直接在使用处使用真实组件或把逻辑写进当前文件。
|
||||
- 页面私有 hook 放在对应页面目录下,例如 `admin/assets/use-admin-assets.ts`;只有多个页面真实复用的 hook 才放到外层 `hooks/`。
|
||||
- 管理后台页面私有组件放到各自页面目录的 `components/` 下,例如 `admin/assets/components/`、`admin/prompts/components/`;不要为了单页面使用放到 `admin/components/` 共享目录。
|
||||
@@ -72,10 +62,9 @@
|
||||
- 后续待办写到 `docs/content/docs/progress/todo.mdx`。
|
||||
- 已实现但还需要用户测试确认的事项写到 `docs/content/docs/progress/pending-test.mdx`。
|
||||
- `docs/content/docs/progress/pending-test.mdx` 用来记录这个版本实际做了哪些可测试变更;`CHANGELOG.md` 的 `Unreleased` 只保留对这些变更的版本级归纳,避免逐条照搬实现细节。
|
||||
- 每次重大改动(新增/调整/删除功能、接口或工具,影响用户可感知行为)完成后,都要在 `CHANGELOG.md` 的 `Unreleased` 追加一条记录,按 `[新增]` / `[调整]` / `[修复]` / `[优化]` 前缀分类,用一句中文归纳;纯内部重构、格式化、无用户可感知影响的小改动可不记。
|
||||
- 每次 todo 事项完成后,先从 `docs/content/docs/progress/todo.mdx` 移到 `docs/content/docs/progress/pending-test.mdx`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/content/docs/overview/features.mdx`。
|
||||
- 每次任务完成前,都要根据实际变更检查并更新 `docs/content/docs/progress/todo.mdx` 和 `docs/content/docs/progress/pending-test.mdx`;如果功能或待办没有变化,也要确认无需修改。
|
||||
- 接口响应规则写到 `docs/content/docs/backend/api-response.mdx`。
|
||||
- 数据库结构写到 `docs/content/docs/backend/backend-database.mdx`。
|
||||
- 文档不要写过期日期;除非用户明确要求记录具体时间。
|
||||
|
||||
## 发版本流程
|
||||
|
||||
@@ -2,6 +2,50 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.7.0 - 2026-07-14
|
||||
|
||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||
+ [新增] 画布新增组节点,支持节点拖入/拆出分组、拖拽高亮吸附和移动组时带动子节点。
|
||||
+ [新增] 画布空白区域支持双击打开节点选择菜单,并在点击位置创建节点。
|
||||
+ [调整] Codex 会话改为站点级连续线程,跨页面和跨画布保持同一上下文。
|
||||
+ [调整] 移除仅前端调用 OpenAI responses 接口,统一走 MCP + 本地 Codex 链路。
|
||||
+ [调整] 移除配置与用户偏好里的 Codex 连接 Tab,本地 Agent 连接统一在 Agent 全站助手内完成。
|
||||
+ [调整] 画布节点顶部工具条改为点击选中节点后显示,避免鼠标经过节点时频繁弹出。
|
||||
+ [优化] 本地 Agent 连接说明明确区分插件 / 手动 MCP 才会增加 Codex token 消耗。
|
||||
+ [优化] 优化本地 Agent 连接说明,区分 Codex 插件启动和直接运行 Agent 两种方式。
|
||||
+ [修复] 修复 Gemini 格式生图时因内置比例列表触发误报导致生成失败的问题。
|
||||
|
||||
## v0.6.0 - 2026-07-09
|
||||
|
||||
+ [新增] 新增Codex App插件支持。
|
||||
+ [新增] 配置与用户偏好新增独立页面和 Codex 连接配置 Tab。
|
||||
+ [新增] 新增GitHub Pages 前端静态站点发布 workflow。
|
||||
+ [新增] 图片切图支持等分线直接拖拽调整,并可新增、删除和重置横向 / 纵向切图线。
|
||||
+ [调整] Docker 运行镜像改为 nginx 静态托管。
|
||||
+ [调整] 移除网站Agent模式,专注于连接Codex Agent操作画布
|
||||
+ [修复] 修复生图工作台重试成功结果刷新后丢失的问题。
|
||||
+ [修复] 修复 Gemini 调用格式生图未传递尺寸比例配置的问题。
|
||||
+ [修复] 修复前端 TypeScript 构建报错。
|
||||
+ [修复] 修复画布生成配置切换文本/视频/音频模式时模型仍显示为生图模型的问题。
|
||||
+ [修复] 兼容中转站视频任务直接返回视频 URL 且没有 `/content` 接口的情况,并优化失败原因展示。
|
||||
|
||||
## v0.5.0 - 2026-07-05
|
||||
|
||||
+ [新增] 渠道兼容Gemini格式。
|
||||
+ [调整] 前端从 Next.js 迁移到 Vite,项目改为静态前端构建。
|
||||
+ [调整] 移除已 404 的 EvoLinkAI 提示词来源。
|
||||
|
||||
## v0.4.0 - 2026-06-16
|
||||
|
||||
+ [新增] 新增网页版Agent Loop模式。
|
||||
+ [新增] 支持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`.
|
||||
+6
-34
@@ -1,46 +1,18 @@
|
||||
# 构建 Next.js 前端产物。
|
||||
# 构建 Vite 前端产物。
|
||||
FROM oven/bun:1.3.13 AS web-build
|
||||
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --cache-dir=/root/.bun/install/cache
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --cache-dir=/root/.bun/install/cache
|
||||
COPY VERSION /app/VERSION
|
||||
COPY CHANGELOG.md /app/CHANGELOG.md
|
||||
COPY web ./
|
||||
RUN bun run build
|
||||
|
||||
# 构建 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
|
||||
|
||||
EXPOSE 3000
|
||||
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
|
||||
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && PORT=3000 node server.js"]
|
||||
|
||||
@@ -8,60 +8,65 @@
|
||||
<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 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布。
|
||||
- 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布;
|
||||
- Codex App 插件:提供 Codex app 插件,安装后会自动注册 MCP 并尝试拉起本地 Agent。
|
||||
- 提示词库:浏览器前端直连多个 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 自动配置
|
||||
|
||||
@@ -89,19 +94,17 @@ 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
|
||||
|
||||
## 赞助支持
|
||||
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
|
||||
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",
|
||||
|
||||
+72
-15
@@ -30,13 +30,26 @@ export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments:
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
export function interruptCodexTurn() {
|
||||
if (!codexApp) return false;
|
||||
return codexApp.interruptCurrentTurn();
|
||||
}
|
||||
|
||||
async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: AgentAttachment[], options: CodexRunOptions) {
|
||||
let files: string[] = [];
|
||||
try {
|
||||
files = await writeAttachmentFiles(attachments);
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const threadId = await ensureCodexThread(codexApp, options);
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
let threadId = await ensureCodexThread(codexApp, options, emit);
|
||||
try {
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
} catch (error) {
|
||||
if (!isRecoverableThreadError(error)) throw error;
|
||||
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
|
||||
codexThreadId = "";
|
||||
threadId = await ensureCodexThread(codexApp, { cwd: options.cwd }, emit);
|
||||
await codexApp.startTurn(threadId, prompt, files);
|
||||
}
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
@@ -53,34 +66,39 @@ export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
|
||||
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
const thread = await codexApp.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 }) {
|
||||
export async function listCodexThreads(emit: AgentEmit, options: { cwd: string; searchTerm?: string; limit?: number }) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.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) {
|
||||
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) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
await loadCodexThread(emit, threadId, cwd, false);
|
||||
await codexApp.archiveThread(threadId);
|
||||
}
|
||||
|
||||
@@ -91,13 +109,20 @@ export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
pipeJsonLines(child, emit, "claude");
|
||||
}
|
||||
|
||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
|
||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions, emit: AgentEmit) {
|
||||
if (options.threadId) {
|
||||
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 +131,10 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions)
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
function isRecoverableThreadError(error: unknown) {
|
||||
return /thread not loaded|no rollout found/i.test(errorMessage(error));
|
||||
}
|
||||
|
||||
class CodexAppClient {
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
@@ -155,8 +184,8 @@ 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) {
|
||||
@@ -176,6 +205,16 @@ class CodexAppClient {
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
||||
}
|
||||
|
||||
interruptCurrentTurn() {
|
||||
if (this.activeTurns.size === 0) return false;
|
||||
try {
|
||||
this.child.kill("SIGINT");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private request(method: string, params: unknown) {
|
||||
const id = this.nextId++;
|
||||
this.write({ id, method, params });
|
||||
@@ -291,6 +330,24 @@ function normalizeCodexNotification(method: string, params: Json): AgentEvent |
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadCodexThread(emit: AgentEmit, threadId: string, cwd: string | undefined, includeTurns: boolean) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const result = await codexApp.readThread(threadId, includeTurns);
|
||||
const thread = field(result, "thread") || {};
|
||||
assertThreadWorkspace(thread, cwd);
|
||||
return thread;
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
@@ -7,6 +7,18 @@ import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
|
||||
const SITE_TOOLS = new Set<ToolName>([
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
]);
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
@@ -18,13 +30,14 @@ export class CanvasSession {
|
||||
|
||||
openEvents(url: URL, res: ServerResponse) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
const statusOnly = url.searchParams.get("role") === "status";
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
this.clients.set(clientId, res);
|
||||
if (!statusOnly) this.clients.set(clientId, res);
|
||||
sendEvent(res, "hello", { ok: true, clientId });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
this.clients.delete(clientId);
|
||||
if (!statusOnly) this.clients.delete(clientId);
|
||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
||||
});
|
||||
}
|
||||
@@ -48,6 +61,10 @@ export class CanvasSession {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
|
||||
if (SITE_TOOLS.has(tool)) {
|
||||
if (!this.clients.size) throw new Error("当前没有已连接网页");
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
const readTool = ["canvas_get_state", "canvas_get_selection", "canvas_export_snapshot"].includes(tool);
|
||||
if (readTool && (!this.clients.size || !this.canvasState)) throw new Error("当前没有已连接画布");
|
||||
if (tool === "canvas_get_state" || tool === "canvas_export_snapshot") return compactCanvasState(this.canvasState);
|
||||
|
||||
+15
-21
@@ -7,10 +7,10 @@ export const DEFAULT_PORT = 17371;
|
||||
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
|
||||
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
|
||||
export const VERSION = readPackageVersion();
|
||||
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
||||
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网站。切换网站页面用 site_navigate,可跳 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image、/video、/prompts、/assets、/config。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。若当前不在画布页,画布工具会报错,需先用 site_navigate 打开画布。想了解或打开用户已有画布,用 canvas_list_projects 获取画布清单和 id,再用 site_navigate 跳 /canvas/:id 打开。生图工作台可用 workbench_image_get_config 看可选项、workbench_image_generate 填提示词并生成;视频创作台对应 workbench_video_get_config 与 workbench_video_generate;用 prompts_search 分页搜索提示词库;用 assets_list 查看「我的素材」、assets_add 新增文本或图片素材。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
||||
|
||||
export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
||||
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
|
||||
export type SiteWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
||||
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; workspace?: SiteWorkspaceConfig };
|
||||
|
||||
export function loadConfig(create = false): CanvasAgentConfig {
|
||||
try {
|
||||
@@ -27,30 +27,28 @@ export function saveConfig(config: CanvasAgentConfig) {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
export function ensureCanvasWorkspace(config: CanvasAgentConfig, canvasId: string) {
|
||||
const id = safeSegment(canvasId || "default");
|
||||
config.canvases ||= {};
|
||||
const current = config.canvases[id];
|
||||
export function ensureSiteWorkspace(config: CanvasAgentConfig) {
|
||||
const current = config.workspace;
|
||||
if (current?.workspacePath) {
|
||||
fs.mkdirSync(resolveWorkspacePath(current.workspacePath), { recursive: true });
|
||||
return { canvasId: id, ...current, workspacePath: resolveWorkspacePath(current.workspacePath) };
|
||||
const workspacePath = resolveWorkspacePath(current.workspacePath);
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
return { ...current, workspacePath };
|
||||
}
|
||||
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id);
|
||||
config.canvases[id] = { workspacePath };
|
||||
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", "site");
|
||||
config.workspace = { workspacePath };
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
saveConfig(config);
|
||||
return { canvasId: id, workspacePath };
|
||||
return { workspacePath };
|
||||
}
|
||||
|
||||
export function updateCanvasWorkspace(config: CanvasAgentConfig, canvasId: string, patch: Partial<CanvasWorkspaceConfig>) {
|
||||
const current = ensureCanvasWorkspace(config, canvasId);
|
||||
export function updateSiteWorkspace(config: CanvasAgentConfig, patch: Partial<SiteWorkspaceConfig>) {
|
||||
const current = ensureSiteWorkspace(config);
|
||||
const workspacePath = patch.workspacePath ? resolveWorkspacePath(patch.workspacePath) : current.workspacePath;
|
||||
const next = { ...current, ...patch, workspacePath };
|
||||
config.canvases ||= {};
|
||||
config.canvases[current.canvasId] = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
|
||||
config.workspace = { workspacePath: next.workspacePath, activeThreadId: next.activeThreadId, pinnedThreadIds: next.pinnedThreadIds };
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
saveConfig(config);
|
||||
return { canvasId: current.canvasId, ...config.canvases[current.canvasId] };
|
||||
return config.workspace;
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(value: string) {
|
||||
@@ -59,10 +57,6 @@ function resolveWorkspacePath(value: string) {
|
||||
return path.resolve(value);
|
||||
}
|
||||
|
||||
function safeSegment(value: string) {
|
||||
return value.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 120) || "default";
|
||||
}
|
||||
|
||||
function readPackageVersion() {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js";
|
||||
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "./config.js";
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, withAgentPrompt } from "./agents.js";
|
||||
import { archiveCodexThread, interruptCodexTurn, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
||||
import type { AgentAttachment } from "./types.js";
|
||||
|
||||
export function startHttpServer() {
|
||||
@@ -38,54 +38,60 @@ export function startHttpServer() {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
|
||||
app.get("/agent/codex/workspace", (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
||||
app.get("/agent/codex/workspace", (_req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
res.json({ ok: true, workspace });
|
||||
});
|
||||
app.get("/agent/codex/threads", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
||||
const 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) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId });
|
||||
updateSiteWorkspace(config, { activeThreadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, ...(await readCodexThread(emit, threadId)) });
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId);
|
||||
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: undefined });
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
if (workspace.activeThreadId === threadId) updateSiteWorkspace(config, { activeThreadId: undefined });
|
||||
res.json({ ok: true });
|
||||
}));
|
||||
app.post("/agent/codex/turn", route(async (req, res) => {
|
||||
const attachments = Array.isArray(req.body?.attachments) ? (req.body.attachments as AgentAttachment[]) : [];
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
const workspace = ensureSiteWorkspace(config);
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
updateSiteWorkspace(config, { activeThreadId: threadId });
|
||||
}
|
||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
||||
res.json({ ok: true, threadId });
|
||||
}));
|
||||
app.post("/agent/codex/interrupt", (_req, res) => {
|
||||
const ok = interruptCodexTurn();
|
||||
res.json({ ok });
|
||||
});
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
|
||||
res.json({ ok: true });
|
||||
@@ -97,7 +103,9 @@ export function startHttpServer() {
|
||||
console.log("Infinite Canvas Agent");
|
||||
console.log(`Local URL: ${config.url}`);
|
||||
console.log(`Connect token: ${config.token}`);
|
||||
console.log("Codex MCP: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
|
||||
console.log("Codex MCP is not installed by this command.");
|
||||
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp");
|
||||
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
|
||||
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
|
||||
|
||||
export const toolNames = [
|
||||
"site_navigate",
|
||||
"canvas_list_projects",
|
||||
"canvas_get_state",
|
||||
"canvas_get_selection",
|
||||
"canvas_export_snapshot",
|
||||
@@ -30,6 +32,13 @@ export const toolNames = [
|
||||
"canvas_select_nodes",
|
||||
"canvas_set_viewport",
|
||||
"canvas_run_generation",
|
||||
"workbench_image_get_config",
|
||||
"workbench_image_generate",
|
||||
"workbench_video_get_config",
|
||||
"workbench_video_generate",
|
||||
"prompts_search",
|
||||
"assets_list",
|
||||
"assets_add",
|
||||
] as const;
|
||||
export type ToolName = (typeof toolNames)[number];
|
||||
|
||||
@@ -37,6 +46,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,6 +86,8 @@ const generationFlowSchema = z.object({
|
||||
});
|
||||
|
||||
export const toolInputSchemas = {
|
||||
site_navigate: z.object({ path: z.string() }),
|
||||
canvas_list_projects: z.object({ keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
|
||||
canvas_get_state: z.object({}).passthrough(),
|
||||
canvas_get_selection: z.object({}).passthrough(),
|
||||
canvas_export_snapshot: z.object({}).passthrough(),
|
||||
@@ -99,13 +111,22 @@ export const toolInputSchemas = {
|
||||
canvas_select_nodes: z.object({ ids: z.array(z.string()) }),
|
||||
canvas_set_viewport: z.object({ viewport: viewportSchema }),
|
||||
canvas_run_generation: z.object({ nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }),
|
||||
workbench_image_get_config: z.object({}).passthrough(),
|
||||
workbench_image_generate: z.object({ prompt: z.string(), model: z.string().optional(), quality: z.string().optional(), size: z.string().optional(), count: z.number().optional(), run: z.boolean().optional() }),
|
||||
workbench_video_get_config: z.object({}).passthrough(),
|
||||
workbench_video_generate: z.object({ prompt: z.string(), model: z.string().optional(), size: z.string().optional(), seconds: z.string().optional(), resolution: z.string().optional(), generateAudio: z.boolean().optional(), watermark: z.boolean().optional(), run: z.boolean().optional() }),
|
||||
prompts_search: z.object({ keyword: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), page: z.number().optional(), pageSize: z.number().optional() }),
|
||||
assets_list: z.object({ kind: z.enum(["all", "text", "image", "video"]).optional(), keyword: z.string().optional(), page: z.number().optional(), pageSize: z.number().optional() }),
|
||||
assets_add: z.object({ kind: z.enum(["text", "image"]), title: z.string(), content: z.string().optional(), imageUrl: z.string().optional(), tags: z.array(z.string()).optional(), source: z.string().optional(), note: z.string().optional() }),
|
||||
} satisfies Record<ToolName, z.AnyZodObject>;
|
||||
|
||||
export const toolDescriptions: Record<ToolName, string> = {
|
||||
site_navigate: "跳转网站页面。path 可为 / (首页)、/canvas (我的画布)、/canvas/:id (指定画布)、/image (生图工作台)、/video (视频创作台)、/prompts (提示词库)、/assets (我的素材)、/config (配置)。操作画布前若不在画布页,先用本工具打开画布。",
|
||||
canvas_list_projects: "列出用户全部画布(仅标题、创建/更新时间、节点数、连线数,不含完整数据),支持 keyword 搜索和 page/pageSize 分页。返回的 id 可配合 site_navigate 跳转到 /canvas/:id 打开对应画布。",
|
||||
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
|
||||
canvas_get_selection: "读取当前网页画布选中的节点。",
|
||||
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
||||
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_text_node: "在当前画布创建单个文本节点。",
|
||||
canvas_create_text_nodes: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
@@ -125,4 +146,11 @@ export const toolDescriptions: Record<ToolName, string> = {
|
||||
canvas_select_nodes: "设置当前选中节点。",
|
||||
canvas_set_viewport: "调整画布视口。",
|
||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
workbench_image_get_config: "读取生图工作台的当前参数和可选项(可用模型、质量、尺寸/宽高比、张数范围),在调用 workbench_image_generate 前先了解可选值。",
|
||||
workbench_image_generate: "在生图工作台填入提示词并按需设置 model、quality、size(如 1:1 或 1024x1024)、count,run 默认 true 会自动点击生成按钮。会自动跳转到生图工作台。生成为异步过程,工具返回代表已提交,结果请在工作台查看。",
|
||||
workbench_video_get_config: "读取视频创作台的当前参数和可选项(可用模型、尺寸/比例、时长、清晰度/分辨率、是否生成声音与水印)。",
|
||||
workbench_video_generate: "在视频创作台填入提示词并按需设置 model、size、seconds、resolution、generateAudio、watermark,run 默认 true 会自动点击生成按钮。会自动跳转到视频创作台。生成为异步过程,工具返回代表已提交。",
|
||||
prompts_search: "搜索提示词库(第三方提示词合集),支持 keyword、category、tags 过滤和 page/pageSize 分页,返回标题、提示词、分类、标签、封面等。",
|
||||
assets_list: "列出用户「我的素材」,支持 kind(text/image/video)过滤、keyword 搜索和 page/pageSize 分页。为控制体积不返回图片/视频原始 data,仅返回封面与元信息。",
|
||||
assets_add: "向「我的素材」新增素材。kind=text 时用 content 传文本内容;kind=image 时用 imageUrl 传图片地址或 dataURL。可附带 title、tags、source、note。",
|
||||
};
|
||||
|
||||
@@ -1,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
|
||||
|
||||
@@ -2,10 +2,6 @@ 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
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -53,8 +53,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,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,6 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"api-response",
|
||||
"system-settings",
|
||||
"backend-database",
|
||||
"canvas-data-structure"
|
||||
]
|
||||
}
|
||||
@@ -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,6 @@ cd docs
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## 数据目录
|
||||
## 数据说明
|
||||
|
||||
`docker-compose.yml` 会把本地 `./data` 挂载到容器内 `/app/data`,用于保存 SQLite 数据库、提示词数据和上传素材。
|
||||
|
||||
Docker 部署时建议把 `.env` 中的 SQLite 路径设置为:
|
||||
|
||||
```text
|
||||
DATABASE_DSN=/app/data/infinite-canvas.db
|
||||
```
|
||||
|
||||
如果需要让火山方舟拉取本地上传的 Seedance 参考素材,还需要把 `PUBLIC_BASE_URL` 设置为公网可访问的站点地址。
|
||||
当前主应用镜像只启动 Next.js。画布、我的素材、生成记录和 AI API Key 默认保存在浏览器本地;第三方提示词由浏览器前端直连拉取,不需要额外挂载数据目录。
|
||||
|
||||
@@ -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` 或文档列出的其他模型名。
|
||||
|
||||
可配置项:
|
||||
|
||||
@@ -91,7 +88,7 @@ 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` 生成可供火山服务器拉取的公开链接。
|
||||
视频生成可从文本节点读取 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;本地素材会以前端可读取的数据传给兼容接口,是否支持取决于具体上游。
|
||||
|
||||
## 画布助手
|
||||
|
||||
@@ -121,15 +118,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 +139,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 的有效期。
|
||||
- 画布更适合桌面端使用,移动端触控体验还未系统完善。
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
---
|
||||
title: 本地 Codex 连接画布原理
|
||||
description: 说明 Canvas Agent、MCP、SSE 和浏览器画布之间如何交互
|
||||
---
|
||||
|
||||
# 本地 Codex 连接画布原理
|
||||
|
||||
本地 Codex 连接画布的核心不是让 Codex 直接控制浏览器,也不是模拟鼠标点击或操作 DOM,而是在用户电脑上运行一个 `Canvas Agent` 作为桥接服务。
|
||||
|
||||
```txt
|
||||
Codex / 浏览器侧边栏
|
||||
|
|
||||
v
|
||||
本机 Canvas Agent
|
||||
|
|
||||
v
|
||||
浏览器画布 React 状态
|
||||
```
|
||||
|
||||
真正的画布数据仍然在浏览器里,节点、连线、选区和视口都由前端 React 状态维护。Codex 只能通过 `Canvas Agent` 暴露的工具协议发出“读取画布”或“执行画布操作”的请求。
|
||||
|
||||
## 两个使用方向
|
||||
|
||||
### 1. 本地 Codex 主动连接画布
|
||||
|
||||
这个方向的入口在 Codex。
|
||||
|
||||
```txt
|
||||
本地 Codex
|
||||
-> infinite-canvas MCP 工具
|
||||
-> 本机 Canvas Agent
|
||||
-> 浏览器画布
|
||||
```
|
||||
|
||||
用户在本地 Codex 里发起请求,例如读取画布、创建文本节点、连接节点或触发生成。Codex 调用 `infinite-canvas` MCP 工具,MCP 工具再请求本机 `Canvas Agent` 的 `/api/tools` 接口。
|
||||
|
||||
常见工具包括:
|
||||
|
||||
- `canvas_get_state`:读取当前画布摘要。
|
||||
- `canvas_get_selection`:读取当前选区。
|
||||
- `canvas_apply_ops`:批量执行画布操作。
|
||||
- `canvas_create_text_node`:创建文本节点。
|
||||
- `canvas_generate_image` / `canvas_generate_text`:创建生成流程并触发生成。
|
||||
- `canvas_update_node` / `canvas_connect_nodes`:更新节点或连接节点。
|
||||
|
||||
这条链路适合在 Codex 对话里直接让 Codex 操作当前打开的画布。
|
||||
|
||||
### 2. 浏览器右侧面板主动打开 Codex
|
||||
|
||||
这个方向的入口在浏览器。
|
||||
|
||||
```txt
|
||||
浏览器右侧 Codex 面板
|
||||
-> 本机 Canvas Agent
|
||||
-> Codex app-server --stdio
|
||||
-> infinite-canvas MCP 工具
|
||||
-> 本机 Canvas Agent
|
||||
-> 浏览器画布
|
||||
```
|
||||
|
||||
用户在网页右侧面板输入提示词后,浏览器把请求发送到 `Canvas Agent` 的 `/agent/codex/turn` 接口。`Canvas Agent` 会在本机启动或复用 `codex app-server --stdio`,并给这个 Codex 会话注入同一套 `infinite-canvas` MCP 工具。
|
||||
|
||||
所以即使入口在浏览器右侧面板,真正改动画布时仍然会回到同一条工具链路:
|
||||
|
||||
```txt
|
||||
Codex -> MCP -> Canvas Agent -> 浏览器画布
|
||||
```
|
||||
|
||||
右侧面板只是把“打开 Codex 会话、发送 prompt、展示流式事件”这件事搬到了网页里。
|
||||
|
||||
## 浏览器和 Canvas Agent 如何连接
|
||||
|
||||
`Canvas Agent` 默认监听本机地址:
|
||||
|
||||
```txt
|
||||
http://127.0.0.1:17371
|
||||
```
|
||||
|
||||
启动后会生成 `Connect token`,网页连接时必须携带 token。浏览器连接后会建立一条 SSE 长连接:
|
||||
|
||||
```txt
|
||||
GET /events?token=xxx&clientId=xxx
|
||||
```
|
||||
|
||||
连接成功后,`Canvas Agent` 会发送 `hello` 事件,浏览器把状态更新为已连接。
|
||||
|
||||
## 画布状态如何同步给 Codex
|
||||
|
||||
浏览器会把当前画布快照主动同步给 `Canvas Agent`:
|
||||
|
||||
```txt
|
||||
POST /canvas/state?token=xxx&clientId=xxx
|
||||
```
|
||||
|
||||
快照包含:
|
||||
|
||||
- 当前画布 ID 和标题;
|
||||
- 节点列表;
|
||||
- 连线列表;
|
||||
- 当前选中的节点 ID;
|
||||
- 当前视口位置和缩放。
|
||||
|
||||
`Canvas Agent` 把最近一次快照保存在内存里。Codex 调用 `canvas_get_state` 时,读取的是这份由浏览器同步过来的最新快照,而不是直接读取浏览器 DOM。
|
||||
|
||||
## Codex 如何操作画布
|
||||
|
||||
以创建文本节点为例,流程如下:
|
||||
|
||||
```txt
|
||||
Codex 调 canvas_create_text_node
|
||||
-> MCP POST /api/tools
|
||||
-> Canvas Agent 转成 canvas_apply_ops
|
||||
-> Canvas Agent 通过 SSE 发送 tool_call
|
||||
-> 浏览器执行 add_node
|
||||
-> 浏览器 POST /canvas/result
|
||||
-> Canvas Agent 返回结果给 Codex
|
||||
```
|
||||
|
||||
`Canvas Agent` 会把一些高级工具转换成统一的画布操作:
|
||||
|
||||
```json
|
||||
{
|
||||
"ops": [
|
||||
{
|
||||
"type": "add_node",
|
||||
"nodeType": "text",
|
||||
"metadata": {
|
||||
"content": "文本内容",
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
浏览器收到 `tool_call` 后,调用前端的 `applyCanvasAgentOps`,在 React 状态中执行 `add_node`、`update_node`、`delete_node`、`connect_nodes`、`set_viewport`、`select_nodes` 等操作。执行完成后再把结果通过 `/canvas/result` 回传给 `Canvas Agent`。
|
||||
|
||||
## 工具确认和撤销
|
||||
|
||||
为了避免 Codex 直接改动画布,浏览器侧对 `canvas_apply_ops` 默认有二次确认机制。收到写操作后,右侧面板会先展示待执行工具调用,用户确认后才真正应用到画布。
|
||||
|
||||
执行后,前端会保存最近一次操作前的画布快照,用于撤销上一轮 Agent 工具操作。
|
||||
|
||||
如果关闭工具确认,本地 Codex 可以直接执行画布工具调用,但仍然是通过同一套 `Canvas Agent -> SSE -> 浏览器执行` 的链路。
|
||||
|
||||
## 安全边界
|
||||
|
||||
- `Canvas Agent` 默认只监听 `127.0.0.1`。
|
||||
- 网页连接必须携带本机生成的 `Connect token`。
|
||||
- 第一次带正确 token 连接后,`Canvas Agent` 会记录网页 Origin,避免其他来源复用当前本机 Agent。
|
||||
- Codex 不直接访问浏览器 DOM,也不直接持有画布内部状态。
|
||||
- 线上网页不保存本机 Codex 登录态、API Key 或本地文件权限。
|
||||
- 画布写操作最终由浏览器前端执行,因此可以在前端做确认、撤销和权限控制。
|
||||
|
||||
## 一句话总结
|
||||
|
||||
本地 Codex 连接画布的本质是:
|
||||
|
||||
```txt
|
||||
Codex 调 MCP 工具,Canvas Agent 做本机桥接,浏览器通过 SSE 接收工具调用并在 React 状态中真正修改画布。
|
||||
```
|
||||
|
||||
读状态是:
|
||||
|
||||
```txt
|
||||
浏览器快照 -> Canvas Agent 内存 -> MCP 工具 -> Codex
|
||||
```
|
||||
|
||||
写操作是:
|
||||
|
||||
```txt
|
||||
Codex -> MCP 工具 -> Canvas Agent -> SSE tool_call -> 浏览器 applyOps -> /canvas/result -> Codex
|
||||
```
|
||||
@@ -4,10 +4,12 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"quick-start",
|
||||
"codex-app-plugin",
|
||||
"features",
|
||||
"render",
|
||||
"docker",
|
||||
"third-party-prompt-repositories",
|
||||
"[在线体验](https://canvas.best/)"
|
||||
"[在线体验](https://canvas.best/)",
|
||||
"local-codex-canvas"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 同步浏览器本地数据。
|
||||
|
||||
@@ -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。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"title": "赞助支持",
|
||||
"title": "支持与安全",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"security",
|
||||
"donate",
|
||||
"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、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。
|
||||
+12
-10
@@ -13,22 +13,20 @@
|
||||
- [画布节点操作手册](/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/security)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
|
||||
@@ -40,5 +38,9 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不支持云同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,跨设备可自行配置 WebDAV 同步。
|
||||
- AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
|
||||
## 原理说明
|
||||
|
||||
- [本地 Codex 连接画布原理](/docs/overview/local-codex-canvas)
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cn } from '@/lib/cn';
|
||||
const tabs = [
|
||||
{ title: '项目介绍', href: '/docs/overview/quick-start', prefix: '/docs/overview' },
|
||||
{ title: '操作手册', href: '/docs/canvas/canvas-node-manual', prefix: '/docs/canvas' },
|
||||
{ title: '开发文档', href: '/docs/backend/local-development', prefix: '/docs/backend' },
|
||||
{ title: '开发文档', href: '/docs/development/local-development', prefix: '/docs/development' },
|
||||
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
||||
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
||||
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"framework": "nextjs",
|
||||
"installCommand": "bun install",
|
||||
"buildCommand": "bun run build"
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
server {
|
||||
listen 3000;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "infinite-canvas",
|
||||
"version": "0.1.0",
|
||||
"description": "Use Codex to read and operate Infinite Canvas through the local Canvas Agent MCP server.",
|
||||
"author": {
|
||||
"name": "basketikun",
|
||||
"url": "https://canvas.best/"
|
||||
},
|
||||
"homepage": "https://canvas.best/",
|
||||
"repository": "https://github.com/basketikun/infinite-canvas",
|
||||
"license": "AGPL-3.0",
|
||||
"keywords": ["infinite-canvas", "canvas", "mcp", "agent"],
|
||||
"skills": "./skills/",
|
||||
"mcpServers": "./.mcp.json",
|
||||
"interface": {
|
||||
"displayName": "Infinite Canvas",
|
||||
"shortDescription": "Use Codex to operate the current Infinite Canvas board.",
|
||||
"longDescription": "Connects Codex to the local Canvas Agent MCP server so Codex can inspect the current Infinite Canvas board, create nodes, connect flows, and trigger generation through the web canvas confirmation flow.",
|
||||
"developerName": "basketikun",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Canvas", "MCP", "Creative Workflow"],
|
||||
"websiteURL": "https://canvas.best/",
|
||||
"defaultPrompt": [
|
||||
"打开 Infinite Canvas",
|
||||
"读取当前画布并总结节点结构",
|
||||
"根据选中节点创建一组生图提示词"
|
||||
],
|
||||
"brandColor": "#2563EB",
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/icon.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"infinite-canvas": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@basketikun/canvas-agent", "mcp"],
|
||||
"startup_timeout_sec": 20,
|
||||
"tool_timeout_sec": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Infinite Canvas Codex Plugin
|
||||
|
||||
让 Codex 可以打开并操作 Infinite Canvas。
|
||||
|
||||
## 安装
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
codex plugin marketplace add "$(pwd)"
|
||||
codex plugin add infinite-canvas@infinite-canvas-local
|
||||
```
|
||||
|
||||
Windows PowerShell:
|
||||
|
||||
```powershell
|
||||
git clone https://github.com/basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
codex plugin marketplace add "$PWD"
|
||||
codex plugin add infinite-canvas@infinite-canvas-local
|
||||
```
|
||||
|
||||
Windows CMD 将 `$PWD` 替换为 `%cd%`。
|
||||
|
||||
安装后新建一个 Codex 任务,然后输入:
|
||||
|
||||
```text
|
||||
帮我打开并连接到 Infinite Canvas
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: canvas
|
||||
description: 操作 Infinite Canvas 当前网页画布,读取节点、选区、创建文本节点、创建生成流程、连接节点或触发生成。
|
||||
---
|
||||
|
||||
# Infinite Canvas
|
||||
|
||||
你正在帮助用户操作 Infinite Canvas 网页画布。需要理解或改动画布时,优先使用已配置的 `infinite-canvas` MCP 工具;不要让用户手动复制 JSON、URL 或 token。
|
||||
|
||||
## 工作流
|
||||
|
||||
- 如果用户还没有打开或连接网页画布,使用 `open-canvas` 技能打开 Infinite Canvas,不要要求用户手动复制 URL 或 token。
|
||||
- 操作前先用 `canvas_get_state` 读取当前画布;如果用户明确提到选中内容、当前节点或“这个”,先用 `canvas_get_selection`。
|
||||
- 创建单个文本内容优先用 `canvas_create_text_node`。
|
||||
- 创建生成内容优先用 `canvas_generate_text`、`canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`。
|
||||
- 需要把提示词、配置和生成节点串成流程时,使用 `canvas_create_generation_flow` 或项目已有的流程工具。
|
||||
- 需要批量增删改、移动、连接节点或设置视口时,使用 `canvas_apply_ops`。
|
||||
- 不要模拟鼠标点击,不要要求用户手动复制 JSON。
|
||||
- 写入画布的操作会由网页侧边栏做二次确认,按当前工具结果继续推进即可。
|
||||
|
||||
## 风格
|
||||
|
||||
- 页面文案和画布节点内容默认使用中文。
|
||||
- 生成节点、配置节点和提示词节点要保持结构清晰,方便用户继续编辑。
|
||||
- 批量创建节点时注意给节点留出间距,不要堆叠在同一个位置。
|
||||
- 图片、视频、音频等媒体节点默认保留原始比例;只有用户明确要求自由变形时才改变比例。
|
||||
- 生成流程尽量少而清楚,优先让用户一眼能看懂节点关系。
|
||||
@@ -0,0 +1,58 @@
|
||||
---
|
||||
name: open-canvas
|
||||
description: 打开 Infinite Canvas 在线或本地画布,并自动连接本地 Canvas Agent。用户要求打开、启动、进入或使用 Infinite Canvas 画布时使用。
|
||||
---
|
||||
|
||||
# Open Infinite Canvas
|
||||
|
||||
默认打开在线版。只有用户明确要求使用本地项目时,才启动本地前端。
|
||||
|
||||
## 在线版
|
||||
|
||||
1. 启动本地 Canvas Agent 并保持运行:
|
||||
|
||||
```bash
|
||||
npx -y @basketikun/canvas-agent
|
||||
```
|
||||
|
||||
2. 从启动输出取得 `Local URL` 和 `Connect token`。
|
||||
|
||||
3. 在 Codex 右侧浏览器打开:
|
||||
|
||||
```text
|
||||
https://canvas.best/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
|
||||
```
|
||||
|
||||
## 本地版
|
||||
|
||||
1. 在 Infinite Canvas 项目中启动前端,并使用 Vite 输出的 `Local` 地址:
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
2. 启动本地 Canvas Agent:
|
||||
|
||||
```bash
|
||||
npx -y @basketikun/canvas-agent
|
||||
```
|
||||
|
||||
3. 从启动输出取得 `Local URL` 和 `Connect token`,在 Codex 右侧浏览器打开:
|
||||
|
||||
```text
|
||||
<Vite Local 地址>/canvas?mode=new&agentUrl=<Local URL>&agentToken=<Connect token>
|
||||
```
|
||||
|
||||
## MCP 与连接地址
|
||||
|
||||
插件在新的 Codex 任务中加载时会自动启动 `npx -y @basketikun/canvas-agent mcp`。这个 MCP 进程负责提供画布工具,不提供网页连接服务;
|
||||
上面启动的普通 Canvas Agent 负责提供 `Local URL` 和 `Connect token`。两个进程读取同一份本地配置,因此不需要用户手动填写地址或 token。
|
||||
|
||||
## 打开模式
|
||||
|
||||
用户没有明确指定打开方式时,始终使用 `mode=new` 新建画布。只有用户明确要求时才替换为:
|
||||
|
||||
- 最近画布:`mode=recent`
|
||||
- 自己选择:`mode=choose`
|
||||
+1
-13
@@ -4,19 +4,7 @@ services:
|
||||
runtime: docker
|
||||
plan: free
|
||||
autoDeployTrigger: off
|
||||
healthCheckPath: /api/health
|
||||
healthCheckPath: /
|
||||
envVars:
|
||||
- key: ADMIN_USERNAME
|
||||
value: admin
|
||||
- key: ADMIN_PASSWORD
|
||||
sync: false
|
||||
- key: JWT_SECRET
|
||||
generateValue: true
|
||||
- key: JWT_EXPIRE_HOURS
|
||||
value: 168
|
||||
- key: PORT
|
||||
value: 3000
|
||||
- key: STORAGE_DRIVER
|
||||
value: sqlite
|
||||
- key: DATABASE_DSN
|
||||
value: data/infinite-canvas.db
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListAssets 按查询条件返回素材分页列表。
|
||||
func ListAssets(q model.Query) ([]model.Asset, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := applyAssetFilters(db.Model(&model.Asset{}), q)
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []model.Asset
|
||||
err = tx.Order("updated_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&items).Error
|
||||
return items, total, err
|
||||
}
|
||||
|
||||
// ListAssetTags 返回当前素材查询条件下的全部标签。
|
||||
func ListAssetTags(q model.Query) ([]string, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.Normalize()
|
||||
q.Tags = nil
|
||||
tx := applyAssetFilters(db.Model(&model.Asset{}), q)
|
||||
|
||||
var items []model.Asset
|
||||
if err := tx.Select("tags").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assetTagsFromItems(items), nil
|
||||
}
|
||||
|
||||
// SaveAsset 保存素材,并在更新时保留原创建时间。
|
||||
func SaveAsset(item model.Asset) (model.Asset, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if saved, ok, err := findAsset(db, item.ID); err != nil {
|
||||
return item, err
|
||||
} else if ok && item.CreatedAt == "" {
|
||||
item.CreatedAt = saved.CreatedAt
|
||||
}
|
||||
return item, db.Save(&item).Error
|
||||
}
|
||||
|
||||
// DeleteAsset 删除指定素材。
|
||||
func DeleteAsset(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.Asset{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// applyAssetFilters 应用素材列表的搜索条件。
|
||||
func applyAssetFilters(tx *gorm.DB, q model.Query) *gorm.DB {
|
||||
if q.Keyword != "" {
|
||||
like := "%" + q.Keyword + "%"
|
||||
tx = tx.Where("title LIKE ? OR description LIKE ? OR content LIKE ?", like, like, like)
|
||||
}
|
||||
if isActiveAssetOption(q.Type) {
|
||||
tx = tx.Where("type = ?", q.Type)
|
||||
}
|
||||
return applyAssetTagsFilter(tx, q.Tags)
|
||||
}
|
||||
|
||||
// findAsset 根据 ID 查询素材。
|
||||
func findAsset(db *gorm.DB, id string) (model.Asset, bool, error) {
|
||||
item := model.Asset{}
|
||||
err := db.Where("id = ?", id).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.Asset{}, false, nil
|
||||
}
|
||||
return item, err == nil, err
|
||||
}
|
||||
|
||||
// applyAssetTagsFilter 应用 JSON 标签条件。
|
||||
func applyAssetTagsFilter(tx *gorm.DB, tags []string) *gorm.DB {
|
||||
if len(tags) == 0 {
|
||||
return tx
|
||||
}
|
||||
condition := tx.Session(&gorm.Session{NewDB: true})
|
||||
for _, tag := range tags {
|
||||
condition = condition.Or(assetJSONTagsContains(tx), tag)
|
||||
}
|
||||
return tx.Where(condition)
|
||||
}
|
||||
|
||||
func assetTagsFromItems(items []model.Asset) []string {
|
||||
seen := map[string]bool{}
|
||||
tags := []string{}
|
||||
for _, item := range items {
|
||||
for _, tag := range item.Tags {
|
||||
if tag != "" && !seen[tag] {
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// assetJSONTagsContains 返回素材 tags 的 JSON 包含条件。
|
||||
func assetJSONTagsContains(tx *gorm.DB) string {
|
||||
switch tx.Dialector.Name() {
|
||||
case "mysql":
|
||||
return "JSON_CONTAINS(tags, JSON_QUOTE(?))"
|
||||
case "postgres":
|
||||
return "jsonb_exists(tags::jsonb, ?)"
|
||||
default:
|
||||
return "EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)"
|
||||
}
|
||||
}
|
||||
|
||||
// isActiveAssetOption 判断素材筛选项有效状态。
|
||||
func isActiveAssetOption(value string) bool {
|
||||
return value != "" && value != "全部" && value != "all"
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var promptCategories = []model.PromptCategory{
|
||||
{Category: "system", Name: "系统", Description: "系统提示词分类"},
|
||||
{Category: "gpt-image-2-prompts", Name: "GPT Image 2 Prompts", Description: "EvoLinkAI 的 GPT Image 2 案例提示词分类", GithubURL: "https://github.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts", Remote: true},
|
||||
{Category: "awesome-gpt-image", Name: "Awesome GPT Image", Description: "ZeroLu 的中文 GPT Image 提示词分类", GithubURL: "https://github.com/ZeroLu/awesome-gpt-image", Remote: true},
|
||||
{Category: "awesome-gpt4o-image-prompts", Name: "Awesome GPT4o Image Prompts", Description: "ImgEdify 的 GPT-4o 图像提示词分类", GithubURL: "https://github.com/ImgEdify/Awesome-GPT4o-Image-Prompts", Remote: true},
|
||||
{Category: "youmind-gpt-image-2", Name: "YouMind GPT Image 2", Description: "YouMind OpenLab 的 GPT Image 2 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-gpt-image-2", Remote: true},
|
||||
{Category: "youmind-nano-banana-pro", Name: "YouMind Nano Banana Pro", Description: "YouMind OpenLab 的 Nano Banana Pro 中文提示词分类", GithubURL: "https://github.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts", Remote: true},
|
||||
{Category: "davidwu-gpt-image2-prompts", Name: "awesome-gpt-image2-prompts", Description: "davidwuw0811-boop 整理的 GPT Image 2 提示词分类", GithubURL: "https://github.com/davidwuw0811-boop/awesome-gpt-image2-prompts", Remote: true},
|
||||
}
|
||||
|
||||
var (
|
||||
db *gorm.DB
|
||||
dbOnce sync.Once
|
||||
dbErr error
|
||||
)
|
||||
|
||||
// DB 初始化并返回全局数据库连接。
|
||||
func DB() (*gorm.DB, error) {
|
||||
dbOnce.Do(func() {
|
||||
driver := strings.ToLower(strings.TrimSpace(config.Cfg.StorageDriver))
|
||||
if driver == "" {
|
||||
driver = "sqlite"
|
||||
}
|
||||
dsn := config.Cfg.DatabaseDSN
|
||||
if driver == "sqlite" && dsn != ":memory:" {
|
||||
_ = os.MkdirAll(filepath.Dir(dsn), 0755)
|
||||
}
|
||||
if isPostgresDriver(driver) {
|
||||
dbErr = ensurePostgresDatabase(dsn)
|
||||
if dbErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if driver == "mysql" {
|
||||
dbErr = ensureMySQLDatabase(dsn)
|
||||
if dbErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
db, dbErr = gorm.Open(dialector(driver, dsn), &gorm.Config{})
|
||||
if dbErr != nil {
|
||||
return
|
||||
}
|
||||
dbErr = db.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.CreditLog{},
|
||||
&model.Prompt{},
|
||||
&model.Asset{},
|
||||
&model.Setting{},
|
||||
)
|
||||
})
|
||||
return db, dbErr
|
||||
}
|
||||
|
||||
func dialector(driver string, dsn string) gorm.Dialector {
|
||||
switch driver {
|
||||
case "mysql":
|
||||
return gormmysql.Open(dsn)
|
||||
case "postgres", "postgresql":
|
||||
return postgres.Open(dsn)
|
||||
default:
|
||||
return sqlite.Open(dsn)
|
||||
}
|
||||
}
|
||||
|
||||
func isPostgresDriver(driver string) bool {
|
||||
return driver == "postgres" || driver == "postgresql"
|
||||
}
|
||||
|
||||
func ensureMySQLDatabase(dsn string) error {
|
||||
cfg, err := mysqldriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := strings.TrimSpace(cfg.DBName)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
targetDB, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = targetDB.PingContext(ctx)
|
||||
_ = targetDB.Close()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !isMySQLError(err, 1049) {
|
||||
return err
|
||||
}
|
||||
|
||||
maintenance := cfg.Clone()
|
||||
maintenance.DBName = ""
|
||||
serverDB, err := sql.Open("mysql", maintenance.FormatDSN())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer serverDB.Close()
|
||||
|
||||
_, err = serverDB.ExecContext(ctx, "CREATE DATABASE "+quoteMySQLIdentifier(target)+" CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
|
||||
if isMySQLError(err, 1007) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ensurePostgresDatabase(dsn string) error {
|
||||
cfg, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := strings.TrimSpace(cfg.Database)
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
conn, err := pgx.ConnectConfig(ctx, cfg)
|
||||
if err == nil {
|
||||
_ = conn.Close(ctx)
|
||||
return nil
|
||||
}
|
||||
if !isPostgresError(err, "3D000") {
|
||||
return err
|
||||
}
|
||||
|
||||
maintenance := cfg.Copy()
|
||||
maintenance.Database = "postgres"
|
||||
if strings.EqualFold(target, "postgres") {
|
||||
maintenance.Database = "template1"
|
||||
}
|
||||
conn, err = pgx.ConnectConfig(ctx, maintenance)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
_, err = conn.Exec(ctx, "CREATE DATABASE "+pgx.Identifier{target}.Sanitize(), pgx.QueryExecModeExec)
|
||||
if isPostgresError(err, "42P04") {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isMySQLError(err error, number uint16) bool {
|
||||
var mysqlErr *mysqldriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == number
|
||||
}
|
||||
|
||||
func isPostgresError(err error, code string) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == code
|
||||
}
|
||||
|
||||
func quoteMySQLIdentifier(name string) string {
|
||||
return "`" + strings.ReplaceAll(name, "`", "``") + "`"
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PromptCategories 返回内置提示词分类的副本。
|
||||
func PromptCategories() []model.PromptCategory {
|
||||
result := make([]model.PromptCategory, len(promptCategories))
|
||||
copy(result, promptCategories)
|
||||
return result
|
||||
}
|
||||
|
||||
// PromptCategoryByCode 根据分类编码查找内置提示词分类。
|
||||
func PromptCategoryByCode(category string) (model.PromptCategory, bool) {
|
||||
for _, item := range promptCategories {
|
||||
if item.Category == category {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return model.PromptCategory{}, false
|
||||
}
|
||||
|
||||
// ListPromptCategories 返回内置提示词分类。
|
||||
func ListPromptCategories() ([]model.PromptCategory, error) {
|
||||
return PromptCategories(), nil
|
||||
}
|
||||
|
||||
// ListPrompts 按查询条件返回提示词分页列表。
|
||||
func ListPrompts(q model.Query) ([]model.Prompt, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := applyPromptFilters(db.Model(&model.Prompt{}), q)
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var items []model.Prompt
|
||||
if err := tx.Order("updated_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&items).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
categories, _ := ListPromptCategories()
|
||||
githubURLs := map[string]string{}
|
||||
for _, item := range categories {
|
||||
githubURLs[item.Category] = item.GithubURL
|
||||
}
|
||||
for i := range items {
|
||||
items[i].GithubURL = githubURLs[items[i].Category]
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
// ListPromptTags 返回当前提示词查询条件下的全部标签。
|
||||
func ListPromptTags(q model.Query) ([]string, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q.Normalize()
|
||||
q.Tags = nil
|
||||
tx := applyPromptFilters(db.Model(&model.Prompt{}), q)
|
||||
|
||||
var items []model.Prompt
|
||||
if err := tx.Select("tags").Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return promptTagsFromItems(items), nil
|
||||
}
|
||||
|
||||
// SavePrompt 保存提示词,并在更新时保留原创建时间。
|
||||
func SavePrompt(item model.Prompt) (model.Prompt, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return item, err
|
||||
}
|
||||
if saved, ok, err := findPrompt(db, item.ID); err != nil {
|
||||
return item, err
|
||||
} else if ok && item.CreatedAt == "" {
|
||||
item.CreatedAt = saved.CreatedAt
|
||||
}
|
||||
item.GithubURL = ""
|
||||
return item, db.Save(&item).Error
|
||||
}
|
||||
|
||||
// DeletePrompt 删除指定提示词。
|
||||
func DeletePrompt(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.Prompt{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// DeletePrompts 批量删除提示词。
|
||||
func DeletePrompts(ids []string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.Prompt{}, "id IN ?", ids).Error
|
||||
}
|
||||
|
||||
// ReplacePromptCategory 用远程同步结果替换整个提示词分类。
|
||||
func ReplacePromptCategory(category model.PromptCategory, items []model.Prompt) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("category = ?", category.Category).Delete(&model.Prompt{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
for i := range items {
|
||||
items[i].Category = category.Category
|
||||
items[i].GithubURL = ""
|
||||
}
|
||||
return tx.Create(&items).Error
|
||||
})
|
||||
}
|
||||
|
||||
// applyPromptFilters 应用提示词列表的搜索条件。
|
||||
func applyPromptFilters(tx *gorm.DB, q model.Query) *gorm.DB {
|
||||
if q.Keyword != "" {
|
||||
like := "%" + q.Keyword + "%"
|
||||
tx = tx.Where("title LIKE ? OR prompt LIKE ?", like, like)
|
||||
}
|
||||
if isActivePromptOption(q.Category) {
|
||||
tx = tx.Where("category = ?", q.Category)
|
||||
}
|
||||
return applyPromptTagsFilter(tx, q.Tags)
|
||||
}
|
||||
|
||||
// findPrompt 根据 ID 查询提示词。
|
||||
func findPrompt(db *gorm.DB, id string) (model.Prompt, bool, error) {
|
||||
item := model.Prompt{}
|
||||
err := db.Where("id = ?", id).First(&item).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.Prompt{}, false, nil
|
||||
}
|
||||
return item, err == nil, err
|
||||
}
|
||||
|
||||
// applyPromptTagsFilter 应用 JSON 标签条件。
|
||||
func applyPromptTagsFilter(tx *gorm.DB, tags []string) *gorm.DB {
|
||||
if len(tags) == 0 {
|
||||
return tx
|
||||
}
|
||||
condition := tx.Session(&gorm.Session{NewDB: true})
|
||||
for _, tag := range tags {
|
||||
condition = condition.Or(promptJSONTagsContains(tx), tag)
|
||||
}
|
||||
return tx.Where(condition)
|
||||
}
|
||||
|
||||
func promptTagsFromItems(items []model.Prompt) []string {
|
||||
seen := map[string]bool{}
|
||||
tags := []string{}
|
||||
for _, item := range items {
|
||||
for _, tag := range item.Tags {
|
||||
if tag != "" && !seen[tag] {
|
||||
seen[tag] = true
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
// promptJSONTagsContains 返回提示词 tags 的 JSON 包含条件。
|
||||
func promptJSONTagsContains(tx *gorm.DB) string {
|
||||
switch tx.Dialector.Name() {
|
||||
case "mysql":
|
||||
return "JSON_CONTAINS(tags, JSON_QUOTE(?))"
|
||||
case "postgres":
|
||||
return "jsonb_exists(tags::jsonb, ?)"
|
||||
default:
|
||||
return "EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)"
|
||||
}
|
||||
}
|
||||
|
||||
// isActivePromptOption 判断提示词筛选项有效状态。
|
||||
func isActivePromptOption(value string) bool {
|
||||
return value != "" && value != "全部" && value != "all"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// GetSettings 返回 public 和 private 两行配置。
|
||||
func GetSettings() (model.Settings, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
var items []model.Setting
|
||||
if err := db.Find(&items).Error; err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
result := model.Settings{}
|
||||
for _, item := range items {
|
||||
if item.Key == model.SettingKeyPrivate {
|
||||
_ = json.Unmarshal(item.Value, &result.Private)
|
||||
} else if item.Key == model.SettingKeyPublic {
|
||||
_ = json.Unmarshal(item.Value, &result.Public)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// SaveSettings 保存 public 和 private 两行配置。
|
||||
func SaveSettings(settings model.Settings, now string) (model.Settings, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return settings, err
|
||||
}
|
||||
publicValue, _ := json.Marshal(settings.Public)
|
||||
privateValue, _ := json.Marshal(settings.Private)
|
||||
items := []model.Setting{
|
||||
{Key: model.SettingKeyPublic, Value: publicValue, CreatedAt: now, UpdatedAt: now},
|
||||
{Key: model.SettingKeyPrivate, Value: privateValue, CreatedAt: now, UpdatedAt: now},
|
||||
}
|
||||
err = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value", "updated_at"}),
|
||||
}).Create(&items).Error
|
||||
return settings, err
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ListUsers 分页查询用户。
|
||||
func ListUsers(q model.Query) ([]model.User, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := db.Model(&model.User{})
|
||||
if keyword := strings.TrimSpace(q.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
tx = tx.Where("username LIKE ? OR display_name LIKE ? OR email LIKE ? OR linux_do_id LIKE ?", like, like, like, like)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var users []model.User
|
||||
err = tx.Order("created_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&users).Error
|
||||
return users, total, err
|
||||
}
|
||||
|
||||
// CountUsers 返回用户总数。
|
||||
func CountUsers() (int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var total int64
|
||||
return total, db.Model(&model.User{}).Count(&total).Error
|
||||
}
|
||||
|
||||
// HasAdmin 判断系统中是否存在管理员。
|
||||
func HasAdmin() (bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var total int64
|
||||
err = db.Model(&model.User{}).Where("role = ?", model.UserRoleAdmin).Count(&total).Error
|
||||
return total > 0, err
|
||||
}
|
||||
|
||||
// GetUserByID 根据 ID 查询用户。
|
||||
func GetUserByID(id string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
return findUser(db, "id = ?", id)
|
||||
}
|
||||
|
||||
// GetUserByUsername 根据用户名查询用户。
|
||||
func GetUserByUsername(username string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
return findUser(db, "username = ?", username)
|
||||
}
|
||||
|
||||
// SaveUser 保存用户信息。
|
||||
func SaveUser(user model.User) (model.User, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, db.Save(&user).Error
|
||||
}
|
||||
|
||||
func ConsumeUserCredits(id string, credits int, now string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
if credits <= 0 {
|
||||
user, ok, err := GetUserByID(id)
|
||||
return user, ok, err
|
||||
}
|
||||
tx := db.Model(&model.User{}).Where("id = ? AND credits >= ?", id, credits).Updates(map[string]any{
|
||||
"credits": gorm.Expr("credits - ?", credits),
|
||||
"updated_at": now,
|
||||
})
|
||||
if tx.Error != nil {
|
||||
return model.User{}, false, tx.Error
|
||||
}
|
||||
user, ok, err := GetUserByID(id)
|
||||
return user, ok && tx.RowsAffected > 0, err
|
||||
}
|
||||
|
||||
func RefundUserCredits(id string, credits int, now string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
if credits <= 0 {
|
||||
user, ok, err := GetUserByID(id)
|
||||
return user, ok, err
|
||||
}
|
||||
tx := db.Model(&model.User{}).Where("id = ?", id).Updates(map[string]any{
|
||||
"credits": gorm.Expr("credits + ?", credits),
|
||||
"updated_at": now,
|
||||
})
|
||||
if tx.Error != nil {
|
||||
return model.User{}, false, tx.Error
|
||||
}
|
||||
user, ok, err := GetUserByID(id)
|
||||
return user, ok && tx.RowsAffected > 0, err
|
||||
}
|
||||
|
||||
// SaveCreditLog 保存算力点变更流水。
|
||||
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return log, err
|
||||
}
|
||||
return log, db.Save(&log).Error
|
||||
}
|
||||
|
||||
func ListCreditLogs(q model.Query) ([]model.CreditLog, int64, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
q.Normalize()
|
||||
tx := db.Model(&model.CreditLog{})
|
||||
if keyword := strings.TrimSpace(q.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
tx = tx.Where("user_id LIKE ? OR type LIKE ? OR remark LIKE ? OR related_id LIKE ?", like, like, like, like)
|
||||
}
|
||||
var total int64
|
||||
if err := tx.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var logs []model.CreditLog
|
||||
err = tx.Order("created_at desc").Offset(q.Offset()).Limit(q.PageSize).Find(&logs).Error
|
||||
return logs, total, err
|
||||
}
|
||||
|
||||
func DeleteCreditLog(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.CreditLog{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// DeleteUser 删除指定用户。
|
||||
func DeleteUser(id string) error {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Delete(&model.User{}, "id = ?", id).Error
|
||||
}
|
||||
|
||||
// GetUserByLinuxDoID 根据 Linux.do ID 查询用户。
|
||||
func GetUserByLinuxDoID(id string) (model.User, bool, error) {
|
||||
db, err := DB()
|
||||
if err != nil {
|
||||
return model.User{}, false, err
|
||||
}
|
||||
return findUser(db, "linux_do_id = ?", id)
|
||||
}
|
||||
|
||||
// findUser 查询单个用户,并将未命中转换为 ok=false。
|
||||
func findUser(db *gorm.DB, query string, args ...any) (model.User, bool, error) {
|
||||
user := model.User{}
|
||||
err := db.Where(query, args...).First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, false, nil
|
||||
}
|
||||
return user, err == nil, err
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/handler"
|
||||
"github.com/basketikun/infinite-canvas/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func New() *gin.Engine {
|
||||
router := gin.Default()
|
||||
router.RedirectTrailingSlash = false
|
||||
_ = router.SetTrustedProxies(nil)
|
||||
api := router.Group("/api")
|
||||
api.GET("/health", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "ok")
|
||||
})
|
||||
api.POST("/auth/register", gin.WrapF(handler.Register))
|
||||
api.POST("/auth/login", gin.WrapF(handler.Login))
|
||||
api.GET("/auth/linux-do/authorize", gin.WrapF(handler.LinuxDoAuthorize))
|
||||
api.GET("/auth/linux-do/callback", gin.WrapF(handler.LinuxDoCallback))
|
||||
api.GET("/auth/me", middleware.OptionalAuth, gin.WrapF(handler.CurrentUser))
|
||||
api.GET("/settings", gin.WrapF(handler.Settings))
|
||||
api.GET("/media/references/:id", func(c *gin.Context) {
|
||||
handler.ReferenceMedia(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
api.HEAD("/media/references/:id", func(c *gin.Context) {
|
||||
handler.ReferenceMedia(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
v1 := api.Group("/v1", middleware.UserAuth)
|
||||
v1.POST("/images/generations", gin.WrapF(handler.AIImagesGenerations))
|
||||
v1.POST("/images/edits", gin.WrapF(handler.AIImagesEdits))
|
||||
v1.POST("/chat/completions", gin.WrapF(handler.AIChatCompletions))
|
||||
v1.POST("/audio/speech", gin.WrapF(handler.AIAudioSpeech))
|
||||
v1.POST("/videos", gin.WrapF(handler.AIVideos))
|
||||
v1.POST("/media/references", gin.WrapF(handler.UploadReferenceMedia))
|
||||
v1.GET("/videos/:id", func(c *gin.Context) {
|
||||
handler.AIVideo(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
v1.GET("/videos/:id/content", func(c *gin.Context) {
|
||||
handler.AIVideoContent(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
api.GET("/prompts", middleware.OptionalAuth, gin.WrapF(handler.Prompts))
|
||||
api.GET("/assets", middleware.OptionalAuth, gin.WrapF(handler.Assets))
|
||||
api.POST("/admin/login", gin.WrapF(handler.AdminLogin))
|
||||
|
||||
admin := api.Group("/admin", middleware.AdminAuth)
|
||||
admin.GET("/users", gin.WrapF(handler.AdminUsers))
|
||||
admin.POST("/users", gin.WrapF(handler.AdminSaveUser))
|
||||
admin.POST("/users/:id/credits", func(c *gin.Context) {
|
||||
handler.AdminAdjustUserCredits(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
admin.DELETE("/users/:id", func(c *gin.Context) {
|
||||
handler.AdminDeleteUser(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
admin.GET("/credit-logs", gin.WrapF(handler.AdminCreditLogs))
|
||||
admin.POST("/credit-logs", gin.WrapF(handler.AdminSaveCreditLog))
|
||||
admin.DELETE("/credit-logs/:id", func(c *gin.Context) {
|
||||
handler.AdminDeleteCreditLog(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
admin.GET("/settings", gin.WrapF(handler.AdminSettings))
|
||||
admin.POST("/settings", gin.WrapF(handler.AdminSaveSettings))
|
||||
admin.POST("/settings/channel-models", gin.WrapF(handler.AdminChannelModels))
|
||||
admin.POST("/settings/channel-test", gin.WrapF(handler.AdminTestChannelModel))
|
||||
admin.GET("/prompt-categories", gin.WrapF(handler.AdminPromptCategories))
|
||||
admin.POST("/prompt-categories/sync", gin.WrapF(handler.AdminSyncPromptCategories))
|
||||
admin.GET("/prompts", gin.WrapF(handler.AdminPrompts))
|
||||
admin.POST("/prompts", gin.WrapF(handler.AdminSavePrompt))
|
||||
admin.POST("/prompts/batch-delete", gin.WrapF(handler.AdminDeletePrompts))
|
||||
admin.DELETE("/prompts/:id", func(c *gin.Context) {
|
||||
handler.AdminDeletePrompt(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
admin.GET("/assets", gin.WrapF(handler.AdminAssets))
|
||||
admin.POST("/assets", gin.WrapF(handler.AdminSaveAsset))
|
||||
admin.DELETE("/assets/:id", func(c *gin.Context) {
|
||||
handler.AdminDeleteAsset(c.Writer, c.Request, c.Param("id"))
|
||||
})
|
||||
|
||||
router.NoRoute(middleware.NotFoundJSON)
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
func ListAssets(q model.Query) (model.AssetList, error) {
|
||||
items, total, err := repository.ListAssets(q)
|
||||
if err != nil {
|
||||
return model.AssetList{}, err
|
||||
}
|
||||
tags, err := repository.ListAssetTags(q)
|
||||
if err != nil {
|
||||
return model.AssetList{}, err
|
||||
}
|
||||
return model.AssetList{Items: items, Tags: tags, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func SaveAsset(item model.Asset) (model.Asset, error) {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if item.Type == "" {
|
||||
item.Type = model.AssetTypeText
|
||||
}
|
||||
if item.ID == "" {
|
||||
item.ID = newID("asset")
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
if item.CoverURL == "" {
|
||||
item.CoverURL = assetCoverURL(item)
|
||||
}
|
||||
return repository.SaveAsset(item)
|
||||
}
|
||||
|
||||
func DeleteAsset(id string) error {
|
||||
return repository.DeleteAsset(id)
|
||||
}
|
||||
|
||||
func assetCoverURL(item model.Asset) string {
|
||||
if item.CoverURL != "" {
|
||||
return item.CoverURL
|
||||
}
|
||||
if item.Type == model.AssetTypeImage {
|
||||
return item.URL
|
||||
}
|
||||
return ""
|
||||
}
|
||||
-590
@@ -1,590 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Role model.UserRole `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type userExtra struct {
|
||||
LinuxDo any `json:"linuxDo,omitempty"`
|
||||
}
|
||||
|
||||
func EnsureDefaultAdmin() error {
|
||||
if strings.TrimSpace(config.Cfg.AdminUsername) == "" || strings.TrimSpace(config.Cfg.AdminPassword) == "" {
|
||||
return nil
|
||||
}
|
||||
WarnDefaultSecurityConfig()
|
||||
hasAdmin, err := repository.HasAdmin()
|
||||
if err != nil || hasAdmin {
|
||||
return err
|
||||
}
|
||||
hash, err := hashPassword(config.Cfg.AdminPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = repository.SaveUser(model.User{
|
||||
ID: newID("user"),
|
||||
Username: strings.TrimSpace(config.Cfg.AdminUsername),
|
||||
Password: hash,
|
||||
Role: model.UserRoleAdmin,
|
||||
AffCode: newAffCode(),
|
||||
Status: model.UserStatusActive,
|
||||
CreatedAt: now(),
|
||||
UpdatedAt: now(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func Register(username string, password string) (model.AuthSession, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
normalizedSettings := normalizeSettings(settings)
|
||||
if normalizedSettings.Public.Auth.AllowRegister != nil && !*normalizedSettings.Public.Auth.AllowRegister {
|
||||
return model.AuthSession{}, safeMessageError{message: "当前未开放注册"}
|
||||
}
|
||||
username = strings.TrimSpace(username)
|
||||
if strings.ContainsAny(username, " \t\r\n") {
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名不能包含空格"}
|
||||
}
|
||||
if username == "" || password == "" {
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名和密码不能为空"}
|
||||
}
|
||||
if _, ok, err := repository.GetUserByUsername(username); err != nil || ok {
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名已存在"}
|
||||
}
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
user, err := repository.SaveUser(model.User{
|
||||
ID: newID("user"),
|
||||
Username: username,
|
||||
Password: hash,
|
||||
Role: model.UserRoleUser,
|
||||
AffCode: newAffCode(),
|
||||
Status: model.UserStatusActive,
|
||||
CreatedAt: now(),
|
||||
UpdatedAt: now(),
|
||||
})
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return newSession(user)
|
||||
}
|
||||
|
||||
func Login(username string, password string) (model.AuthSession, error) {
|
||||
user, ok, err := repository.GetUserByUsername(strings.TrimSpace(username))
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
if !ok || bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return model.AuthSession{}, safeMessageError{message: "用户名或密码错误"}
|
||||
}
|
||||
if user.Status == model.UserStatusBan {
|
||||
return model.AuthSession{}, safeMessageError{message: "账号已被禁用"}
|
||||
}
|
||||
normalizeUserDefaults(&user)
|
||||
user.LastLoginAt = now()
|
||||
user.UpdatedAt = now()
|
||||
user, err = repository.SaveUser(user)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return newSession(user)
|
||||
}
|
||||
|
||||
func LinuxDoAuthorizeURL(r *http.Request, redirect string) (string, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
linuxDo := settings.Private.Auth.LinuxDo
|
||||
if !settings.Public.Auth.LinuxDo.Enabled {
|
||||
return "", safeMessageError{message: "Linux.do 登录未开启"}
|
||||
}
|
||||
if strings.TrimSpace(linuxDo.ClientID) == "" || strings.TrimSpace(linuxDo.ClientSecret) == "" {
|
||||
return "", safeMessageError{message: "Linux.do 登录未配置"}
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("client_id", linuxDo.ClientID)
|
||||
values.Set("redirect_uri", linuxDoRedirectURI(r))
|
||||
values.Set("response_type", "code")
|
||||
values.Set("scope", "read")
|
||||
values.Set("state", base64.RawURLEncoding.EncodeToString([]byte(redirect)))
|
||||
return config.Cfg.LinuxDoAuthorizeURL + "?" + values.Encode(), nil
|
||||
}
|
||||
|
||||
func LoginWithLinuxDo(r *http.Request, code string, state string) (model.AuthSession, string, error) {
|
||||
redirect := decodeState(state)
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
linuxDo := settings.Private.Auth.LinuxDo
|
||||
if !settings.Public.Auth.LinuxDo.Enabled {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 登录未开启"}
|
||||
}
|
||||
token, err := linuxDoAccessToken(r, code, linuxDo)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
profile, err := linuxDoProfile(token)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
linuxDoID := fmt.Sprint(profile.ID)
|
||||
if strings.TrimSpace(linuxDoID) == "" || linuxDoID == "0" {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "Linux.do 用户信息无效"}
|
||||
}
|
||||
user, ok, err := repository.GetUserByLinuxDoID(linuxDoID)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
if !ok {
|
||||
if settings.Public.Auth.AllowRegister != nil && !*settings.Public.Auth.AllowRegister {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "当前未开放注册"}
|
||||
}
|
||||
user = model.User{
|
||||
ID: newID("user"),
|
||||
Username: linuxDoUsername(profile.Username, linuxDoID),
|
||||
DisplayName: strings.TrimSpace(profile.Name),
|
||||
AvatarURL: linuxDoAvatar(profile.AvatarTemplate),
|
||||
Role: model.UserRoleUser,
|
||||
AffCode: newAffCode(),
|
||||
LinuxDoID: linuxDoID,
|
||||
Status: model.UserStatusActive,
|
||||
CreatedAt: now(),
|
||||
}
|
||||
} else if user.Status == model.UserStatusBan {
|
||||
return model.AuthSession{}, redirect, safeMessageError{message: "账号已被禁用"}
|
||||
}
|
||||
user.DisplayName = firstNonEmpty(profile.Name, user.DisplayName)
|
||||
user.AvatarURL = firstNonEmpty(linuxDoAvatar(profile.AvatarTemplate), user.AvatarURL)
|
||||
user.LastLoginAt = now()
|
||||
user.UpdatedAt = now()
|
||||
extra, _ := json.Marshal(userExtra{LinuxDo: profile})
|
||||
user.Extra = string(extra)
|
||||
user, err = repository.SaveUser(user)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, redirect, err
|
||||
}
|
||||
session, err := newSession(user)
|
||||
return session, redirect, err
|
||||
}
|
||||
|
||||
func ParseToken(tokenText string) (TokenClaims, error) {
|
||||
claims := TokenClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenText, &claims, func(token *jwt.Token) (any, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("登录状态无效")
|
||||
}
|
||||
return []byte(config.Cfg.JWTSecret), nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return TokenClaims{}, errors.New("登录状态无效")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func CurrentAuthUser(tokenText string) (model.AuthUser, bool) {
|
||||
claims, err := ParseToken(tokenText)
|
||||
if err != nil {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
user, ok, err := repository.GetUserByID(claims.UserID)
|
||||
if err != nil || !ok {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
if user.Status == model.UserStatusBan {
|
||||
return model.AuthUser{}, false
|
||||
}
|
||||
return model.PublicUser(user), true
|
||||
}
|
||||
|
||||
func ListUsers(q model.Query) (model.UserList, error) {
|
||||
users, total, err := repository.ListUsers(q)
|
||||
if err != nil {
|
||||
return model.UserList{}, err
|
||||
}
|
||||
for i := range users {
|
||||
users[i].Password = ""
|
||||
normalizeUserDefaults(&users[i])
|
||||
}
|
||||
return model.UserList{Items: users, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func SaveUser(user model.User, password string) (model.User, error) {
|
||||
user.Username = strings.TrimSpace(user.Username)
|
||||
if strings.ContainsAny(user.Username, " \t\r\n") {
|
||||
return user, safeMessageError{message: "用户名不能包含空格"}
|
||||
}
|
||||
if user.Username == "" {
|
||||
return user, safeMessageError{message: "用户名不能为空"}
|
||||
}
|
||||
if user.Role == "" || user.Role == model.UserRoleGuest {
|
||||
user.Role = model.UserRoleUser
|
||||
}
|
||||
if user.Status == "" {
|
||||
user.Status = model.UserStatusActive
|
||||
}
|
||||
if saved, ok, err := repository.GetUserByUsername(user.Username); err != nil {
|
||||
return user, err
|
||||
} else if ok && saved.ID != user.ID {
|
||||
return user, safeMessageError{message: "用户名已存在"}
|
||||
}
|
||||
isCreate := user.ID == ""
|
||||
if isCreate {
|
||||
user.ID = newID("user")
|
||||
user.AffCode = newAffCode()
|
||||
user.CreatedAt = now()
|
||||
} else if saved, ok, err := repository.GetUserByID(user.ID); err != nil {
|
||||
return user, err
|
||||
} else if ok {
|
||||
user.CreatedAt = saved.CreatedAt
|
||||
user.Password = saved.Password
|
||||
user.AvatarURL = saved.AvatarURL
|
||||
user.Credits = saved.Credits
|
||||
user.Extra = saved.Extra
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = saved.AffCode
|
||||
}
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = newAffCode()
|
||||
}
|
||||
if user.LinuxDoID == "" {
|
||||
user.LinuxDoID = saved.LinuxDoID
|
||||
}
|
||||
user.LastLoginAt = saved.LastLoginAt
|
||||
}
|
||||
if password != "" {
|
||||
hash, err := hashPassword(password)
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
user.Password = hash
|
||||
}
|
||||
if isCreate && user.Password == "" {
|
||||
return user, safeMessageError{message: "密码不能为空"}
|
||||
}
|
||||
user.UpdatedAt = now()
|
||||
user, err := repository.SaveUser(user)
|
||||
user.Password = ""
|
||||
return user, err
|
||||
}
|
||||
|
||||
func AdjustUserCredits(id string, credits int) (model.User, error) {
|
||||
user, ok, err := repository.GetUserByID(id)
|
||||
if err != nil || !ok {
|
||||
if err != nil {
|
||||
return user, err
|
||||
}
|
||||
return user, safeMessageError{message: "用户不存在"}
|
||||
}
|
||||
oldCredits := user.Credits
|
||||
user.Credits = credits
|
||||
user.UpdatedAt = now()
|
||||
user, err = repository.SaveUser(user)
|
||||
if err == nil && oldCredits != credits {
|
||||
_, err = repository.SaveCreditLog(model.CreditLog{
|
||||
ID: newID("credit"),
|
||||
UserID: user.ID,
|
||||
Type: model.CreditLogTypeAdminAdjust,
|
||||
Amount: credits - oldCredits,
|
||||
Balance: credits,
|
||||
Remark: "后台手动调整",
|
||||
CreatedAt: now(),
|
||||
})
|
||||
}
|
||||
user.Password = ""
|
||||
return user, err
|
||||
}
|
||||
|
||||
func ConsumeUserCredits(userID string, modelName string, credits int, path string) error {
|
||||
if credits <= 0 {
|
||||
return nil
|
||||
}
|
||||
user, ok, err := repository.ConsumeUserCredits(userID, credits, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return safeMessageError{message: "算力点不足"}
|
||||
}
|
||||
extra, _ := json.Marshal(map[string]string{"model": modelName, "path": path})
|
||||
_, err = repository.SaveCreditLog(model.CreditLog{
|
||||
ID: newID("credit"),
|
||||
UserID: userID,
|
||||
Type: model.CreditLogTypeAIConsume,
|
||||
Amount: -credits,
|
||||
Balance: user.Credits,
|
||||
Remark: "调用模型 " + modelName,
|
||||
Extra: string(extra),
|
||||
CreatedAt: now(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func RefundUserCredits(userID string, modelName string, credits int, path string) error {
|
||||
if credits <= 0 {
|
||||
return nil
|
||||
}
|
||||
user, ok, err := repository.RefundUserCredits(userID, credits, now())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return safeMessageError{message: "用户不存在"}
|
||||
}
|
||||
extra, _ := json.Marshal(map[string]string{"model": modelName, "path": path})
|
||||
_, err = repository.SaveCreditLog(model.CreditLog{
|
||||
ID: newID("credit"),
|
||||
UserID: userID,
|
||||
Type: model.CreditLogTypeAIRefund,
|
||||
Amount: credits,
|
||||
Balance: user.Credits,
|
||||
Remark: "模型调用失败返还 " + modelName,
|
||||
Extra: string(extra),
|
||||
CreatedAt: now(),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func ListCreditLogs(q model.Query) (model.CreditLogList, error) {
|
||||
logs, total, err := repository.ListCreditLogs(q)
|
||||
if err != nil {
|
||||
return model.CreditLogList{}, err
|
||||
}
|
||||
return model.CreditLogList{Items: logs, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func SaveCreditLog(log model.CreditLog) (model.CreditLog, error) {
|
||||
if log.ID == "" {
|
||||
log.ID = newID("credit")
|
||||
log.CreatedAt = now()
|
||||
}
|
||||
return repository.SaveCreditLog(log)
|
||||
}
|
||||
|
||||
func DeleteCreditLog(id string) error {
|
||||
return repository.DeleteCreditLog(id)
|
||||
}
|
||||
|
||||
func DeleteUser(id string) error {
|
||||
return repository.DeleteUser(id)
|
||||
}
|
||||
|
||||
func GuestUser() model.AuthUser {
|
||||
return model.AuthUser{ID: "", Username: "guest", Role: model.UserRoleGuest}
|
||||
}
|
||||
|
||||
func newSession(user model.User) (model.AuthSession, error) {
|
||||
token, err := newToken(user)
|
||||
if err != nil {
|
||||
return model.AuthSession{}, err
|
||||
}
|
||||
return model.AuthSession{Token: token, User: model.PublicUser(user)}, nil
|
||||
}
|
||||
|
||||
func newToken(user model.User) (string, error) {
|
||||
expireHours := config.Cfg.JWTExpireHours
|
||||
if expireHours <= 0 {
|
||||
expireHours = 168
|
||||
}
|
||||
claims := TokenClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireHours) * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Subject: user.ID,
|
||||
},
|
||||
}
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.Cfg.JWTSecret))
|
||||
}
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func now() string {
|
||||
return time.Now().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func newID(prefix string) string {
|
||||
return prefix + "-" + uuid.NewString()
|
||||
}
|
||||
|
||||
func newAffCode() string {
|
||||
return strings.ToUpper(strings.ReplaceAll(uuid.NewString()[:8], "-", ""))
|
||||
}
|
||||
|
||||
func normalizeUserDefaults(user *model.User) {
|
||||
if user.Status == "" {
|
||||
user.Status = model.UserStatusActive
|
||||
}
|
||||
if user.AffCode == "" {
|
||||
user.AffCode = newAffCode()
|
||||
}
|
||||
}
|
||||
|
||||
type linuxDoTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
|
||||
type linuxDoUserResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Name string `json:"name"`
|
||||
AvatarTemplate string `json:"avatar_template"`
|
||||
}
|
||||
|
||||
func linuxDoAccessToken(r *http.Request, code string, setting model.PrivateLinuxDoAuthSetting) (string, error) {
|
||||
values := url.Values{}
|
||||
values.Set("client_id", setting.ClientID)
|
||||
values.Set("client_secret", setting.ClientSecret)
|
||||
values.Set("grant_type", "authorization_code")
|
||||
values.Set("code", code)
|
||||
values.Set("redirect_uri", linuxDoRedirectURI(r))
|
||||
req, _ := http.NewRequest(http.MethodPost, config.Cfg.LinuxDoTokenURL, strings.NewReader(values.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
var payload linuxDoTokenResponse
|
||||
if err := doLinuxDoJSON(req, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return "", safeMessageError{message: "Linux.do 登录失败"}
|
||||
}
|
||||
return payload.AccessToken, nil
|
||||
}
|
||||
|
||||
func linuxDoRedirectURI(r *http.Request) string {
|
||||
return RequestOrigin(r) + "/api/auth/linux-do/callback"
|
||||
}
|
||||
|
||||
func linuxDoProfile(token string) (linuxDoUserResponse, error) {
|
||||
req, _ := http.NewRequest(http.MethodGet, config.Cfg.LinuxDoUserInfoURL, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
var payload linuxDoUserResponse
|
||||
err := doLinuxDoJSON(req, &payload)
|
||||
return payload, err
|
||||
}
|
||||
|
||||
func doLinuxDoJSON(req *http.Request, payload any) error {
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return safeMessageError{message: "Linux.do 登录失败"}
|
||||
}
|
||||
return json.NewDecoder(bytes.NewReader(body)).Decode(payload)
|
||||
}
|
||||
|
||||
func linuxDoUsername(username string, id string) string {
|
||||
base := strings.TrimSpace(username)
|
||||
if base == "" {
|
||||
base = "linuxdo-" + id
|
||||
}
|
||||
if _, ok, err := repository.GetUserByUsername(base); err != nil || !ok {
|
||||
return base
|
||||
}
|
||||
return base + "-" + id
|
||||
}
|
||||
|
||||
func linuxDoAvatar(template string) string {
|
||||
if strings.TrimSpace(template) == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(template, "//") {
|
||||
template = "https:" + template
|
||||
}
|
||||
if strings.HasPrefix(template, "/") {
|
||||
template = "https://linux.do" + template
|
||||
}
|
||||
return strings.ReplaceAll(template, "{size}", "120")
|
||||
}
|
||||
|
||||
func decodeState(state string) string {
|
||||
data, err := base64.RawURLEncoding.DecodeString(state)
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
return safeRedirectPath(string(data))
|
||||
}
|
||||
|
||||
// safeRedirectPath 仅放行站内相对路径,拦截开放重定向。浏览器会忽略 URL 中的
|
||||
// Tab/换行/回车,并把 //host 或 /\host 解析为协议相对的跨站地址,因此先剥离这些
|
||||
// 控制字符,再拒绝 // 与 /\ 前缀。
|
||||
func safeRedirectPath(redirect string) string {
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if r == '\t' || r == '\n' || r == '\r' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, redirect)
|
||||
if !strings.HasPrefix(cleaned, "/") || strings.HasPrefix(cleaned, "//") || strings.HasPrefix(cleaned, "/\\") {
|
||||
return "/"
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func RequestOrigin(r *http.Request) string {
|
||||
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
||||
if proto == "" {
|
||||
proto = "http"
|
||||
}
|
||||
return proto + "://" + host
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func WarnDefaultSecurityConfig() {
|
||||
if config.Cfg.AdminUsername == "admin" && config.Cfg.AdminPassword == "infinite-canvas" {
|
||||
log.Println("WARNING: using default admin credentials, please set ADMIN_USERNAME and ADMIN_PASSWORD to safer values before deployment")
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSafeRedirectPath(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"/": "/",
|
||||
"/canvas/abc": "/canvas/abc",
|
||||
"/login?redirect=/x": "/login?redirect=/x",
|
||||
"": "/",
|
||||
"//evil.com": "/",
|
||||
"/\\evil.com": "/",
|
||||
"https://evil.com": "/",
|
||||
"http://evil.com": "/",
|
||||
"javascript:alert(1)": "/",
|
||||
"evil.com": "/",
|
||||
"/\t/evil.com": "/", // browsers strip the tab → //evil.com
|
||||
"/normal\tpath": "/normalpath",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := safeRedirectPath(in); got != want {
|
||||
t.Errorf("safeRedirectPath(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeStateRejectsOpenRedirect(t *testing.T) {
|
||||
for _, in := range []string{"//evil.com", "/\\evil.com", "https://evil.com"} {
|
||||
state := base64.RawURLEncoding.EncodeToString([]byte(in))
|
||||
if got := decodeState(state); got != "/" {
|
||||
t.Errorf("decodeState(state(%q)) = %q, want \"/\"", in, got)
|
||||
}
|
||||
}
|
||||
state := base64.RawURLEncoding.EncodeToString([]byte("/canvas/1"))
|
||||
if got := decodeState(state); got != "/canvas/1" {
|
||||
t.Errorf("decodeState(state(/canvas/1)) = %q, want /canvas/1", got)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
)
|
||||
|
||||
type userContextKey struct{}
|
||||
|
||||
func WithUser(ctx context.Context, user model.AuthUser) context.Context {
|
||||
return context.WithValue(ctx, userContextKey{}, user)
|
||||
}
|
||||
|
||||
func UserFromContext(ctx context.Context) (model.AuthUser, bool) {
|
||||
user, ok := ctx.Value(userContextKey{}).(model.AuthUser)
|
||||
return user, ok
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
gptImage2RawBase = "https://raw.githubusercontent.com/EvoLinkAI/awesome-gpt-image-2-API-and-Prompts/main"
|
||||
awesomeGptImageRawBase = "https://raw.githubusercontent.com/ZeroLu/awesome-gpt-image/main"
|
||||
awesomeGpt4oImagePromptsBase = "https://raw.githubusercontent.com/ImgEdify/Awesome-GPT4o-Image-Prompts/main"
|
||||
youMindGptImage2RawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-gpt-image-2/main"
|
||||
youMindNanoBananaProRawBase = "https://raw.githubusercontent.com/YouMind-OpenLab/awesome-nano-banana-pro-prompts/main"
|
||||
davidWuGptImage2RawBase = "https://raw.githubusercontent.com/davidwuw0811-boop/awesome-gpt-image2-prompts/main"
|
||||
)
|
||||
|
||||
var gptImage2CaseFiles = []string{"README.md", "cases/ad-creative.md", "cases/character.md", "cases/comparison.md", "cases/ecommerce.md", "cases/portrait.md", "cases/poster.md", "cases/ui.md"}
|
||||
|
||||
type gptImage2Data struct {
|
||||
Records []struct {
|
||||
Title string `json:"title"`
|
||||
TweetURL string `json:"tweet_url"`
|
||||
ImageDir string `json:"image_dir"`
|
||||
Category string `json:"category"`
|
||||
AddedAt string `json:"added_at"`
|
||||
} `json:"records"`
|
||||
}
|
||||
|
||||
type davidWuGptImage2Prompt struct {
|
||||
ID int `json:"id"`
|
||||
TitleEN string `json:"title_en"`
|
||||
TitleCN string `json:"title_cn"`
|
||||
Category string `json:"category"`
|
||||
CategoryCN string `json:"category_cn"`
|
||||
Prompt string `json:"prompt"`
|
||||
Note string `json:"note"`
|
||||
Author string `json:"author"`
|
||||
Source string `json:"source"`
|
||||
NeedsRef bool `json:"needs_ref"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
func SyncPromptCategory(category string) ([]model.PromptCategory, error) {
|
||||
for _, item := range repository.PromptCategories() {
|
||||
if item.Category != category {
|
||||
continue
|
||||
}
|
||||
items, err := buildPromptCategory(item.Category)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := repository.ReplacePromptCategory(item, items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return repository.ListPromptCategories()
|
||||
}
|
||||
return nil, errors.New("未知提示词分类")
|
||||
}
|
||||
|
||||
func buildPromptCategory(category string) ([]model.Prompt, error) {
|
||||
switch category {
|
||||
case "gpt-image-2-prompts":
|
||||
return buildGptImage2Prompts()
|
||||
case "awesome-gpt-image":
|
||||
return buildAwesomeGptImagePrompts()
|
||||
case "awesome-gpt4o-image-prompts":
|
||||
return buildAwesomeGpt4oImagePrompts()
|
||||
case "youmind-gpt-image-2":
|
||||
return buildYouMindGptImage2Prompts()
|
||||
case "youmind-nano-banana-pro":
|
||||
return buildYouMindNanoBananaProPrompts()
|
||||
case "davidwu-gpt-image2-prompts":
|
||||
return buildDavidWuGptImage2Prompts()
|
||||
}
|
||||
return nil, errors.New("未知提示词分类")
|
||||
}
|
||||
|
||||
func fetchText(baseURL, file string) (string, error) {
|
||||
request, _ := http.NewRequest(http.MethodGet, baseURL+"/"+file, nil)
|
||||
client := http.Client{Timeout: 30 * time.Second}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return "", errors.New(file + " 拉取失败")
|
||||
}
|
||||
data, err := io.ReadAll(response.Body)
|
||||
return string(data), err
|
||||
}
|
||||
|
||||
func buildGptImage2Prompts() ([]model.Prompt, error) {
|
||||
cases := map[string]string{}
|
||||
raw, err := fetchText(gptImage2RawBase, "data/ingested_tweets.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := gptImage2Data{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, file := range gptImage2CaseFiles {
|
||||
markdown, err := fetchText(gptImage2RawBase, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
collectGptImage2Cases(cases, markdown)
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, item := range data.Records {
|
||||
prompt := cases[item.TweetURL]
|
||||
if prompt == "" {
|
||||
continue
|
||||
}
|
||||
image := gptImage2RawBase + "/" + item.ImageDir + "/output.jpg"
|
||||
items = append(items, model.Prompt{ID: "gpt-image-2-prompts-" + leftPad(len(items)+1), Title: item.Title, CoverURL: image, Prompt: prompt, Tags: tagsFromCategory(item.Category), CreatedAt: item.AddedAt, UpdatedAt: item.AddedAt, Preview: markdownPreview([]string{image})})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func collectGptImage2Cases(cases map[string]string, markdown string) {
|
||||
re := regexp.MustCompile("(?s)### Case \\d+: \\[[^\\]]+\\]\\(([^)]+)\\).*?\\*\\*Prompt:\\*\\*\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```")
|
||||
for _, match := range re.FindAllStringSubmatch(markdown, -1) {
|
||||
cases[match[1]] = strings.TrimSpace(match[2])
|
||||
}
|
||||
}
|
||||
|
||||
func buildAwesomeGptImagePrompts() ([]model.Prompt, error) {
|
||||
markdown, err := fetchText(awesomeGptImageRawBase, "README.zh-CN.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, section := range splitBeforeHeading(markdown, "## ") {
|
||||
tags := tagsFromHeading(firstMatch(section, `(?m)^##\s+(.+)$`))
|
||||
for _, block := range splitBeforeHeading(section, "### ") {
|
||||
title := strings.TrimSpace(regexp.MustCompile(`\[([^\]]+)]\([^)]+\)`).ReplaceAllString(firstMatch(block, `(?m)^###\s+(.+)$`), "$1"))
|
||||
prompt := strings.TrimSpace(firstMatch(block, "(?s)\\*\\*提示词:\\*\\*\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```"))
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
images := extractMarkdownImages(awesomeGptImageRawBase, block)
|
||||
cover := ""
|
||||
if len(images) > 0 {
|
||||
cover = images[0]
|
||||
}
|
||||
items = append(items, model.Prompt{ID: "awesome-gpt-image-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: tags, Preview: markdownPreview(images)})
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildAwesomeGpt4oImagePrompts() ([]model.Prompt, error) {
|
||||
markdown, err := fetchText(awesomeGpt4oImagePromptsBase, "README.zh-CN.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, block := range splitBeforeHeading(markdown, "### ") {
|
||||
title := strings.TrimSpace(firstMatch(block, `(?m)^###\s+(.+)$`))
|
||||
prompt := strings.TrimSpace(firstMatch(block, "(?s)- \\*\\*提示词文本:\\*\\*\\s*`(.*?)`"))
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
images := extractMarkdownImages(awesomeGpt4oImagePromptsBase, block)
|
||||
cover := ""
|
||||
if len(images) > 0 {
|
||||
cover = images[0]
|
||||
}
|
||||
items = append(items, model.Prompt{ID: "awesome-gpt4o-image-prompts-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: []string{"gpt4o"}, Preview: markdownPreview(images)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildYouMindGptImage2Prompts() ([]model.Prompt, error) {
|
||||
return buildYouMindPrompts(youMindGptImage2RawBase, "youmind-gpt-image-2", "gpt-image-2")
|
||||
}
|
||||
|
||||
func buildYouMindNanoBananaProPrompts() ([]model.Prompt, error) {
|
||||
return buildYouMindPrompts(youMindNanoBananaProRawBase, "youmind-nano-banana-pro", "nano-banana-pro")
|
||||
}
|
||||
|
||||
func buildDavidWuGptImage2Prompts() ([]model.Prompt, error) {
|
||||
raw, err := fetchText(davidWuGptImage2RawBase, "prompts.json")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := []davidWuGptImage2Prompt{}
|
||||
if err := json.Unmarshal([]byte(raw), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, item := range data {
|
||||
title := strings.TrimSpace(item.TitleCN)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(item.TitleEN)
|
||||
}
|
||||
prompt := strings.TrimSpace(item.Prompt)
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
image := absoluteImage(davidWuGptImage2RawBase, item.Image)
|
||||
items = append(items, model.Prompt{ID: "davidwu-gpt-image2-prompts-" + leftPad(item.ID), Title: title, CoverURL: image, Prompt: prompt, Tags: davidWuGptImage2Tags(item), Preview: davidWuGptImage2Preview(item, image)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func buildYouMindPrompts(baseURL, idPrefix, modelTag string) ([]model.Prompt, error) {
|
||||
markdown, err := fetchText(baseURL, "README_zh.md")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []model.Prompt{}
|
||||
for _, block := range splitBeforeHeading(markdown, "### ") {
|
||||
title := strings.TrimSpace(firstMatch(block, `(?m)^###\s+No\.\s*\d+:\s*(.+)$`))
|
||||
prompt := strings.TrimSpace(firstMatch(block, "(?s)#### .*?提示词\\s*\\r?\\n\\s*```[\\w-]*\\r?\\n(.*?)\\r?\\n```"))
|
||||
if title == "" || prompt == "" {
|
||||
continue
|
||||
}
|
||||
images := extractMarkdownImages(baseURL, block)
|
||||
cover := ""
|
||||
if len(images) > 0 {
|
||||
cover = images[0]
|
||||
}
|
||||
items = append(items, model.Prompt{ID: idPrefix + "-" + leftPad(len(items)+1), Title: title, CoverURL: cover, Prompt: prompt, Tags: youMindTags(title, modelTag), Preview: markdownPreview(images)})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func splitBeforeHeading(markdown string, prefix string) []string {
|
||||
blocks := []string{}
|
||||
lines := strings.Split(markdown, "\n")
|
||||
current := []string{}
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, prefix) && len(current) > 0 {
|
||||
blocks = append(blocks, strings.Join(current, "\n"))
|
||||
current = []string{}
|
||||
}
|
||||
current = append(current, line)
|
||||
}
|
||||
return append(blocks, strings.Join(current, "\n"))
|
||||
}
|
||||
|
||||
func firstMatch(value string, pattern string) string {
|
||||
match := regexp.MustCompile(pattern).FindStringSubmatch(value)
|
||||
if len(match) > 1 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func tagsFromCategory(category string) []string {
|
||||
return splitTags(regexp.MustCompile(`(?i)\s+Cases$`).ReplaceAllString(category, ""), `\s*(&|and)\s*`)
|
||||
}
|
||||
|
||||
func tagsFromHeading(heading string) []string {
|
||||
return splitTags(regexp.MustCompile(`[^\p{L}\p{N}/&、与 ]`).ReplaceAllString(heading, ""), `\s*(/|&|、|与)\s*`)
|
||||
}
|
||||
|
||||
func youMindTags(title, modelTag string) []string {
|
||||
tags := []string{modelTag}
|
||||
parts := strings.SplitN(title, " - ", 2)
|
||||
if len(parts) > 1 {
|
||||
tags = append(tags, tagsFromHeading(parts[0])...)
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func davidWuGptImage2Tags(item davidWuGptImage2Prompt) []string {
|
||||
tags := splitTags(strings.Join([]string{item.CategoryCN, item.Category, item.Author, item.Source}, "/"), `/`)
|
||||
if item.NeedsRef {
|
||||
tags = append(tags, "需要参考图")
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func davidWuGptImage2Preview(item davidWuGptImage2Prompt, image string) string {
|
||||
lines := []string{}
|
||||
if item.TitleEN != "" {
|
||||
lines = append(lines, item.TitleEN)
|
||||
}
|
||||
if item.Note != "" {
|
||||
lines = append(lines, item.Note)
|
||||
}
|
||||
if image != "" {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
func splitTags(value string, pattern string) []string {
|
||||
tags := []string{}
|
||||
for _, tag := range regexp.MustCompile(pattern).Split(value, -1) {
|
||||
if tag = strings.ToLower(strings.TrimSpace(tag)); tag != "" {
|
||||
tags = append(tags, tag)
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
func markdownPreview(images []string) string {
|
||||
lines := []string{}
|
||||
for _, image := range images {
|
||||
if image != "" {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n\n")
|
||||
}
|
||||
|
||||
func extractMarkdownImages(baseURL string, block string) []string {
|
||||
seen := map[string]bool{}
|
||||
images := []string{}
|
||||
for _, pattern := range []string{`<img[^>]+src="([^"]+)"`, `!\[[^\]]*]\(([^)]+)\)`} {
|
||||
for _, match := range regexp.MustCompile(pattern).FindAllStringSubmatch(block, -1) {
|
||||
image := absoluteImage(baseURL, match[1])
|
||||
if image != "" && !seen[image] {
|
||||
seen[image] = true
|
||||
images = append(images, image)
|
||||
}
|
||||
}
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
func absoluteImage(baseURL, image string) string {
|
||||
if image == "" || strings.HasPrefix(image, "http://") || strings.HasPrefix(image, "https://") {
|
||||
return image
|
||||
}
|
||||
return baseURL + "/" + strings.TrimLeft(strings.TrimPrefix(image, "."), "/")
|
||||
}
|
||||
|
||||
func leftPad(value int) string {
|
||||
if value >= 1000 {
|
||||
return strconv.Itoa(value)
|
||||
}
|
||||
text := "000" + strconv.Itoa(value)
|
||||
return text[len(text)-3:]
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
"github.com/robfig/cron/v3"
|
||||
)
|
||||
|
||||
const defaultPromptSyncCron = "*/5 * * * *"
|
||||
|
||||
var (
|
||||
promptSyncCron *cron.Cron
|
||||
promptSyncOnce sync.Once
|
||||
promptSyncMu sync.Mutex
|
||||
)
|
||||
|
||||
func StartPromptSyncScheduler() {
|
||||
promptSyncOnce.Do(func() {
|
||||
promptSyncCron = cron.New()
|
||||
promptSyncCron.Start()
|
||||
})
|
||||
RefreshPromptSyncScheduler()
|
||||
}
|
||||
|
||||
func RefreshPromptSyncScheduler() {
|
||||
promptSyncMu.Lock()
|
||||
defer promptSyncMu.Unlock()
|
||||
if promptSyncCron == nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range promptSyncCron.Entries() {
|
||||
promptSyncCron.Remove(entry.ID)
|
||||
}
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
log.Printf("load prompt sync setting failed err=%v", err)
|
||||
return
|
||||
}
|
||||
setting := normalizePromptSyncSetting(settings.Private.PromptSync)
|
||||
if setting.Enabled == nil || !*setting.Enabled {
|
||||
return
|
||||
}
|
||||
if _, err := promptSyncCron.AddFunc(setting.Cron, SyncRemotePromptCategories); err != nil {
|
||||
log.Printf("add prompt sync cron failed cron=%s err=%v", setting.Cron, err)
|
||||
}
|
||||
}
|
||||
|
||||
func SyncRemotePromptCategories() {
|
||||
for _, category := range repository.PromptCategories() {
|
||||
if !category.Remote {
|
||||
continue
|
||||
}
|
||||
log.Printf("scheduled prompt sync start category=%s", category.Category)
|
||||
if _, err := SyncPromptCategory(category.Category); err != nil {
|
||||
log.Printf("scheduled prompt sync failed category=%s err=%v", category.Category, err)
|
||||
continue
|
||||
}
|
||||
log.Printf("scheduled prompt sync done category=%s", category.Category)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizePromptSyncSetting(setting model.PromptSyncSetting) model.PromptSyncSetting {
|
||||
if setting.Cron == "" {
|
||||
setting.Cron = defaultPromptSyncCron
|
||||
}
|
||||
if setting.Enabled == nil {
|
||||
enabled := true
|
||||
setting.Enabled = &enabled
|
||||
}
|
||||
return setting
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
func ListPrompts(q model.Query) (model.PromptList, error) {
|
||||
items, total, err := repository.ListPrompts(q)
|
||||
if err != nil {
|
||||
return model.PromptList{}, err
|
||||
}
|
||||
tags, err := repository.ListPromptTags(q)
|
||||
if err != nil {
|
||||
return model.PromptList{}, err
|
||||
}
|
||||
categories := promptCategoryCodes(ListPromptCategories())
|
||||
return model.PromptList{Items: items, Tags: tags, Categories: categories, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func ListPromptCategories() []model.PromptCategory {
|
||||
categories, _ := repository.ListPromptCategories()
|
||||
return categories
|
||||
}
|
||||
|
||||
func SavePrompt(item model.Prompt) (model.Prompt, error) {
|
||||
now := time.Now().Format(time.RFC3339)
|
||||
if item.Category == "" {
|
||||
item.Category = repository.PromptCategories()[0].Category
|
||||
}
|
||||
if item.ID == "" {
|
||||
item.ID = newID(item.Category)
|
||||
item.CreatedAt = now
|
||||
}
|
||||
item.UpdatedAt = now
|
||||
category, ok := repository.PromptCategoryByCode(item.Category)
|
||||
if !ok {
|
||||
category = repository.PromptCategories()[0]
|
||||
item.Category = category.Category
|
||||
}
|
||||
item.GithubURL = ""
|
||||
return repository.SavePrompt(item)
|
||||
}
|
||||
|
||||
func DeletePrompt(id string) error {
|
||||
return repository.DeletePrompt(id)
|
||||
}
|
||||
|
||||
func DeletePrompts(ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return repository.DeletePrompts(ids)
|
||||
}
|
||||
|
||||
func promptCategoryCodes(items []model.PromptCategory) []string {
|
||||
codes := []string{}
|
||||
for _, item := range items {
|
||||
if item.Category != "" {
|
||||
codes = append(codes, item.Category)
|
||||
}
|
||||
}
|
||||
return codes
|
||||
}
|
||||
@@ -1,488 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/basketikun/infinite-canvas/repository"
|
||||
)
|
||||
|
||||
var adminModelHTTPClient = &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
func PublicSettings() (model.PublicSetting, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
return normalizeSettings(settings).Public, err
|
||||
}
|
||||
|
||||
func AdminSettings() (model.Settings, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
return hidePrivateAPIKeys(normalizeSettings(settings)), err
|
||||
}
|
||||
|
||||
func SaveSettings(settings model.Settings) (model.Settings, error) {
|
||||
saved, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return model.Settings{}, err
|
||||
}
|
||||
settings = normalizeSettings(settings)
|
||||
keepPrivateAPIKeys(&settings, normalizeSettings(saved))
|
||||
keepPrivateAuthSecrets(&settings, normalizeSettings(saved))
|
||||
result, err := repository.SaveSettings(settings, now())
|
||||
if err == nil {
|
||||
RefreshPromptSyncScheduler()
|
||||
}
|
||||
return hidePrivateAPIKeys(result), err
|
||||
}
|
||||
|
||||
func AdminChannelModels(index *int, channel model.ModelChannel) ([]string, error) {
|
||||
resolved, err := resolveAdminChannel(index, channel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fetchAdminChannelModels(resolved)
|
||||
}
|
||||
|
||||
func AdminTestChannelModel(index *int, channel model.ModelChannel, modelName string) (string, error) {
|
||||
resolved, err := resolveAdminChannel(index, channel)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if isArkAgentPlanChannel(resolved) || isSeedanceModelName(modelName) {
|
||||
return testArkSeedanceChannelModel(resolved, modelName)
|
||||
}
|
||||
return testAdminChannelModel(resolved, modelName)
|
||||
}
|
||||
|
||||
func normalizeSettings(settings model.Settings) model.Settings {
|
||||
settings.Private = normalizePrivateSetting(settings.Private)
|
||||
settings.Public = normalizePublicSettingWithChannels(settings.Public, settings.Private.Channels)
|
||||
return settings
|
||||
}
|
||||
|
||||
func normalizePublicSetting(setting model.PublicSetting) model.PublicSetting {
|
||||
return normalizePublicSettingWithChannels(setting, nil)
|
||||
}
|
||||
|
||||
func normalizePublicSettingWithChannels(setting model.PublicSetting, channels []model.ModelChannel) model.PublicSetting {
|
||||
if setting.ModelChannel.AvailableModels == nil {
|
||||
setting.ModelChannel.AvailableModels = []string{}
|
||||
}
|
||||
if setting.ModelChannel.ModelCosts == nil {
|
||||
setting.ModelChannel.ModelCosts = []model.ModelCost{}
|
||||
}
|
||||
for i := range setting.ModelChannel.ModelCosts {
|
||||
setting.ModelChannel.ModelCosts[i].Model = strings.TrimSpace(setting.ModelChannel.ModelCosts[i].Model)
|
||||
if setting.ModelChannel.ModelCosts[i].Credits < 0 {
|
||||
setting.ModelChannel.ModelCosts[i].Credits = 0
|
||||
}
|
||||
}
|
||||
if setting.ModelChannel.AllowCustomChannel == nil {
|
||||
enabled := true
|
||||
setting.ModelChannel.AllowCustomChannel = &enabled
|
||||
}
|
||||
if setting.Auth.AllowRegister == nil {
|
||||
enabled := true
|
||||
setting.Auth.AllowRegister = &enabled
|
||||
}
|
||||
enabledModels := enabledChannelModels(channels)
|
||||
if len(enabledModels) > 0 {
|
||||
setting.ModelChannel.AvailableModels = enabledModels
|
||||
} else {
|
||||
setting.ModelChannel.AvailableModels = uniqueModelNames(setting.ModelChannel.AvailableModels)
|
||||
}
|
||||
setting.ModelChannel.DefaultTextModel = repairDefaultModel(setting.ModelChannel.DefaultTextModel, setting.ModelChannel.AvailableModels, isTextModelName)
|
||||
setting.ModelChannel.DefaultImageModel = repairDefaultModel(setting.ModelChannel.DefaultImageModel, setting.ModelChannel.AvailableModels, isImageModelName)
|
||||
setting.ModelChannel.DefaultVideoModel = repairDefaultModel(setting.ModelChannel.DefaultVideoModel, setting.ModelChannel.AvailableModels, isVideoModelName)
|
||||
setting.ModelChannel.DefaultModel = repairDefaultModel(setting.ModelChannel.DefaultModel, setting.ModelChannel.AvailableModels, isTextModelName)
|
||||
return setting
|
||||
}
|
||||
|
||||
func ModelCost(modelName string) (int, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
modelName = strings.TrimSpace(modelName)
|
||||
for _, item := range normalizePublicSetting(settings.Public).ModelChannel.ModelCosts {
|
||||
if item.Model == modelName {
|
||||
return item.Credits, nil
|
||||
}
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func normalizePrivateSetting(setting model.PrivateSetting) model.PrivateSetting {
|
||||
if setting.Channels == nil {
|
||||
setting.Channels = []model.ModelChannel{}
|
||||
}
|
||||
setting.PromptSync = normalizePromptSyncSetting(setting.PromptSync)
|
||||
for i := range setting.Channels {
|
||||
if setting.Channels[i].Protocol == "" {
|
||||
setting.Channels[i].Protocol = "openai"
|
||||
}
|
||||
if setting.Channels[i].Models == nil {
|
||||
setting.Channels[i].Models = []string{}
|
||||
}
|
||||
if setting.Channels[i].Weight <= 0 {
|
||||
setting.Channels[i].Weight = 1
|
||||
}
|
||||
}
|
||||
return setting
|
||||
}
|
||||
|
||||
func hidePrivateAPIKeys(settings model.Settings) model.Settings {
|
||||
for i := range settings.Private.Channels {
|
||||
settings.Private.Channels[i].APIKey = ""
|
||||
}
|
||||
settings.Private.Auth.LinuxDo.ClientSecret = ""
|
||||
return settings
|
||||
}
|
||||
|
||||
func keepPrivateAPIKeys(settings *model.Settings, saved model.Settings) {
|
||||
for i := range settings.Private.Channels {
|
||||
if strings.TrimSpace(settings.Private.Channels[i].APIKey) != "" {
|
||||
continue
|
||||
}
|
||||
if channel, ok := findSavedChannel(settings.Private.Channels[i], saved.Private.Channels, i); ok {
|
||||
settings.Private.Channels[i].APIKey = channel.APIKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keepPrivateAuthSecrets(settings *model.Settings, saved model.Settings) {
|
||||
if strings.TrimSpace(settings.Private.Auth.LinuxDo.ClientSecret) == "" {
|
||||
settings.Private.Auth.LinuxDo.ClientSecret = saved.Private.Auth.LinuxDo.ClientSecret
|
||||
}
|
||||
}
|
||||
|
||||
func findSavedChannel(channel model.ModelChannel, saved []model.ModelChannel, index int) (model.ModelChannel, bool) {
|
||||
for _, item := range saved {
|
||||
if item.Name == channel.Name && item.BaseURL == channel.BaseURL {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
if index < len(saved) {
|
||||
return saved[index], true
|
||||
}
|
||||
return model.ModelChannel{}, false
|
||||
}
|
||||
|
||||
func SelectModelChannel(modelName string) (model.ModelChannel, error) {
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return model.ModelChannel{}, err
|
||||
}
|
||||
channels := modelChannelsForModel(normalizePrivateSetting(settings.Private).Channels, modelName)
|
||||
if len(channels) == 0 {
|
||||
return model.ModelChannel{}, errors.New("没有可用模型渠道")
|
||||
}
|
||||
total := 0
|
||||
for _, channel := range channels {
|
||||
total += channel.Weight
|
||||
}
|
||||
hit := rand.Intn(total)
|
||||
for _, channel := range channels {
|
||||
hit -= channel.Weight
|
||||
if hit < 0 {
|
||||
return channel, nil
|
||||
}
|
||||
}
|
||||
return channels[0], nil
|
||||
}
|
||||
|
||||
func BuildModelChannelURL(channel model.ModelChannel, path string) string {
|
||||
baseURL := normalizeModelChannelBaseURL(channel.BaseURL)
|
||||
lowerBaseURL := strings.ToLower(baseURL)
|
||||
if !strings.HasSuffix(lowerBaseURL, "/v1") && !strings.HasSuffix(lowerBaseURL, "/api/v3") && !strings.HasSuffix(lowerBaseURL, "/api/plan/v3") {
|
||||
baseURL += "/v1"
|
||||
}
|
||||
return baseURL + path
|
||||
}
|
||||
|
||||
func normalizeModelChannelBaseURL(baseURL string) string {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err == nil && parsed.Scheme != "" && parsed.Host != "" {
|
||||
path := strings.TrimRight(parsed.Path, "/")
|
||||
lowerPath := strings.ToLower(path)
|
||||
if index := strings.Index(lowerPath, "/api/plan/v3"); index >= 0 {
|
||||
end := index + len("/api/plan/v3")
|
||||
if len(lowerPath) == end || lowerPath[end] == '/' {
|
||||
parsed.Path = path[:end]
|
||||
parsed.RawPath = ""
|
||||
parsed.RawQuery = ""
|
||||
parsed.Fragment = ""
|
||||
return strings.TrimRight(parsed.String(), "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
return baseURL
|
||||
}
|
||||
|
||||
func isArkAgentPlanChannel(channel model.ModelChannel) bool {
|
||||
baseURL := strings.ToLower(normalizeModelChannelBaseURL(channel.BaseURL))
|
||||
return strings.HasSuffix(baseURL, "/api/plan/v3")
|
||||
}
|
||||
|
||||
func isSeedanceModelName(modelName string) bool {
|
||||
modelName = strings.ToLower(strings.TrimSpace(modelName))
|
||||
return strings.Contains(modelName, "seedance") || strings.Contains(modelName, "doubao-seedance")
|
||||
}
|
||||
|
||||
func enabledChannelModels(channels []model.ModelChannel) []string {
|
||||
models := []string{}
|
||||
for _, channel := range channels {
|
||||
if !channel.Enabled {
|
||||
continue
|
||||
}
|
||||
models = append(models, channel.Models...)
|
||||
}
|
||||
return uniqueModelNames(models)
|
||||
}
|
||||
|
||||
func uniqueModelNames(models []string) []string {
|
||||
result := []string{}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range models {
|
||||
name := strings.TrimSpace(item)
|
||||
if name == "" || seen[name] {
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
result = append(result, name)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func repairDefaultModel(current string, models []string, preferred func(string) bool) string {
|
||||
current = strings.TrimSpace(current)
|
||||
for _, item := range models {
|
||||
if item == current {
|
||||
return current
|
||||
}
|
||||
}
|
||||
for _, item := range models {
|
||||
if preferred(item) {
|
||||
return item
|
||||
}
|
||||
}
|
||||
if len(models) > 0 {
|
||||
return models[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isVideoModelName(modelName string) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(modelName))
|
||||
return strings.Contains(name, "seedance") || strings.Contains(name, "video")
|
||||
}
|
||||
|
||||
func isImageModelName(modelName string) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(modelName))
|
||||
return strings.Contains(name, "seedream") || strings.Contains(name, "gpt-image") || strings.Contains(name, "image")
|
||||
}
|
||||
|
||||
func isTextModelName(modelName string) bool {
|
||||
return !isImageModelName(modelName) && !isVideoModelName(modelName)
|
||||
}
|
||||
|
||||
func normalizeModelChannel(channel model.ModelChannel) model.ModelChannel {
|
||||
if channel.Protocol == "" {
|
||||
channel.Protocol = "openai"
|
||||
}
|
||||
if channel.Models == nil {
|
||||
channel.Models = []string{}
|
||||
}
|
||||
if channel.Weight <= 0 {
|
||||
channel.Weight = 1
|
||||
}
|
||||
return channel
|
||||
}
|
||||
|
||||
func resolveAdminChannel(index *int, channel model.ModelChannel) (model.ModelChannel, error) {
|
||||
resolved := normalizeModelChannel(channel)
|
||||
if strings.TrimSpace(resolved.APIKey) == "" {
|
||||
settings, err := repository.GetSettings()
|
||||
if err != nil {
|
||||
return model.ModelChannel{}, err
|
||||
}
|
||||
saved := normalizePrivateSetting(settings.Private).Channels
|
||||
if index != nil && *index >= 0 && *index < len(saved) {
|
||||
if resolved.APIKey == "" {
|
||||
resolved.APIKey = saved[*index].APIKey
|
||||
}
|
||||
if resolved.BaseURL == "" {
|
||||
resolved.BaseURL = saved[*index].BaseURL
|
||||
}
|
||||
if resolved.Name == "" {
|
||||
resolved.Name = saved[*index].Name
|
||||
}
|
||||
}
|
||||
if resolved.APIKey == "" {
|
||||
if savedChannel, ok := findSavedChannel(resolved, saved, -1); ok {
|
||||
resolved.APIKey = savedChannel.APIKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(resolved.BaseURL) == "" {
|
||||
return model.ModelChannel{}, safeMessageError{message: "缺少接口地址"}
|
||||
}
|
||||
if strings.TrimSpace(resolved.APIKey) == "" {
|
||||
return model.ModelChannel{}, safeMessageError{message: "缺少 API Key"}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func fetchAdminChannelModels(channel model.ModelChannel) ([]string, error) {
|
||||
request, err := http.NewRequest(http.MethodGet, BuildModelChannelURL(channel, "/models"), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
|
||||
response, err := adminModelHTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, safeMessageError{message: "读取模型失败:上游接口无响应或网络不可达"}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
if response.StatusCode == http.StatusNotFound && isArkAgentPlanChannel(channel) {
|
||||
return nil, safeMessageError{message: "火山方舟 Agent Plan 未提供 OpenAI /models 模型列表接口,请手动填写模型名称,例如 doubao-seedance-2.0。"}
|
||||
}
|
||||
return nil, readAdminChannelError(body, response.StatusCode, "读取模型失败")
|
||||
}
|
||||
var payload struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
result := make([]string, 0, len(payload.Data))
|
||||
for _, item := range payload.Data {
|
||||
if strings.TrimSpace(item.ID) != "" {
|
||||
result = append(result, item.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func testAdminChannelModel(channel model.ModelChannel, modelName string) (string, error) {
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
return "", errors.New("缺少模型名称")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": modelName,
|
||||
"messages": []map[string]string{{
|
||||
"role": "user",
|
||||
"content": "hi",
|
||||
}},
|
||||
})
|
||||
request, err := http.NewRequest(http.MethodPost, BuildModelChannelURL(channel, "/chat/completions"), strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Authorization", "Bearer "+channel.APIKey)
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := adminModelHTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return "", safeMessageError{message: "测试失败:上游接口无响应或网络不可达"}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
responseBody, _ := io.ReadAll(response.Body)
|
||||
if response.StatusCode >= http.StatusBadRequest {
|
||||
return "", readAdminChannelError(responseBody, response.StatusCode, "测试失败")
|
||||
}
|
||||
var payload struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
_ = json.Unmarshal(responseBody, &payload)
|
||||
if len(payload.Choices) > 0 && strings.TrimSpace(payload.Choices[0].Message.Content) != "" {
|
||||
return payload.Choices[0].Message.Content, nil
|
||||
}
|
||||
return "ok", nil
|
||||
}
|
||||
|
||||
func testArkSeedanceChannelModel(channel model.ModelChannel, modelName string) (string, error) {
|
||||
if strings.TrimSpace(modelName) == "" {
|
||||
return "", errors.New("缺少模型名称")
|
||||
}
|
||||
if strings.TrimSpace(channel.BaseURL) == "" {
|
||||
return "", safeMessageError{message: "缺少接口地址"}
|
||||
}
|
||||
if strings.TrimSpace(channel.APIKey) == "" {
|
||||
return "", safeMessageError{message: "缺少 API Key"}
|
||||
}
|
||||
if !isArkAgentPlanChannel(channel) {
|
||||
return "Seedance 视频模型不会发送 /chat/completions 文本测试。已检查 Base URL、API Key 和模型名非空;未调用视频生成接口,因此未验证套餐额度或模型权限。", nil
|
||||
}
|
||||
return "Agent Plan / Seedance 视频模型配置格式已通过。后台测试不会调用视频生成接口,因此未验证 API Key、套餐额度或模型权限;请在画布中使用视频生成验证。", nil
|
||||
}
|
||||
|
||||
func readAdminChannelError(body []byte, statusCode int, fallback string) error {
|
||||
var payload struct {
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
Msg string `json:"msg"`
|
||||
}
|
||||
if len(body) > 0 && json.Unmarshal(body, &payload) == nil {
|
||||
if payload.Error != nil && strings.TrimSpace(payload.Error.Message) != "" {
|
||||
return safeMessageError{message: payload.Error.Message}
|
||||
}
|
||||
if strings.TrimSpace(payload.Msg) != "" {
|
||||
return safeMessageError{message: payload.Msg}
|
||||
}
|
||||
}
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden {
|
||||
return safeMessageError{message: fmt.Sprintf("上游接口鉴权失败(%d),请检查 API Key、套餐权限或模型权限", statusCode)}
|
||||
}
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
return safeMessageError{message: "上游接口限流或额度不足(429),请稍后重试或检查额度"}
|
||||
}
|
||||
if statusCode > 0 {
|
||||
return safeMessageError{message: fmt.Sprintf("%s:%d", fallback, statusCode)}
|
||||
}
|
||||
return safeMessageError{message: fallback}
|
||||
}
|
||||
|
||||
type safeMessageError struct {
|
||||
message string
|
||||
}
|
||||
|
||||
func (err safeMessageError) Error() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
func (err safeMessageError) SafeMessage() string {
|
||||
return err.message
|
||||
}
|
||||
|
||||
func modelChannelsForModel(channels []model.ModelChannel, modelName string) []model.ModelChannel {
|
||||
result := []model.ModelChannel{}
|
||||
for _, channel := range channels {
|
||||
if !channel.Enabled || channel.BaseURL == "" || channel.APIKey == "" {
|
||||
continue
|
||||
}
|
||||
for _, item := range channel.Models {
|
||||
if strings.TrimSpace(item) == modelName {
|
||||
result = append(result, channel)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
)
|
||||
|
||||
func TestFetchAdminChannelModelsParsesOpenAIModels(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/models" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[{"id":"z-model"},{"id":"a-model"},{"id":""}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
models, err := fetchAdminChannelModels(model.ModelChannel{
|
||||
BaseURL: server.URL,
|
||||
APIKey: "test-key",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAdminChannelModels returned error: %v", err)
|
||||
}
|
||||
if want := []string{"a-model", "z-model"}; !reflect.DeepEqual(models, want) {
|
||||
t.Fatalf("models = %#v, want %#v", models, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAdminChannelModelsReportsArkPlanModelsUnsupported(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/plan/v3/models" {
|
||||
t.Fatalf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := fetchAdminChannelModels(model.ModelChannel{
|
||||
BaseURL: server.URL + "/api/plan/v3/contents/generations/tasks",
|
||||
APIKey: "test-key",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unsupported /models error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Agent Plan 未提供 OpenAI /models") {
|
||||
t.Fatalf("error = %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelChannelURLNormalizesArkPlanTaskPath(t *testing.T) {
|
||||
got := BuildModelChannelURL(model.ModelChannel{BaseURL: "https://ark.cn-beijing.volces.com/api/plan/v3/contents/generations/tasks?debug=1"}, "/models")
|
||||
want := "https://ark.cn-beijing.volces.com/api/plan/v3/models"
|
||||
if got != want {
|
||||
t.Fatalf("BuildModelChannelURL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSettingsPublishesEnabledChannelModelsAndRepairsDefaults(t *testing.T) {
|
||||
settings := normalizeSettings(model.Settings{
|
||||
Public: model.PublicSetting{
|
||||
ModelChannel: model.PublicModelChannelSetting{
|
||||
AvailableModels: []string{"grok-imagine-video", "disabled-model"},
|
||||
DefaultModel: "grok-imagine-video",
|
||||
DefaultTextModel: "missing-text",
|
||||
DefaultImageModel: "missing-image",
|
||||
DefaultVideoModel: "missing-video",
|
||||
},
|
||||
},
|
||||
Private: model.PrivateSetting{
|
||||
Channels: []model.ModelChannel{
|
||||
{Enabled: true, Models: []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast", "gpt-5.5"}},
|
||||
{Enabled: false, Models: []string{"disabled-model"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
channel := settings.Public.ModelChannel
|
||||
wantModels := []string{"gpt-5.5", "doubao-seedream-5.0-lite", "doubao-seedance-2.0-fast"}
|
||||
if !reflect.DeepEqual(channel.AvailableModels, wantModels) {
|
||||
t.Fatalf("available models = %#v, want %#v", channel.AvailableModels, wantModels)
|
||||
}
|
||||
if channel.DefaultModel != "gpt-5.5" {
|
||||
t.Fatalf("default model = %q, want text model", channel.DefaultModel)
|
||||
}
|
||||
if channel.DefaultTextModel != "gpt-5.5" {
|
||||
t.Fatalf("default text model = %q, want text model", channel.DefaultTextModel)
|
||||
}
|
||||
if channel.DefaultImageModel != "doubao-seedream-5.0-lite" {
|
||||
t.Fatalf("default image model = %q, want seedream", channel.DefaultImageModel)
|
||||
}
|
||||
if channel.DefaultVideoModel != "doubao-seedance-2.0-fast" {
|
||||
t.Fatalf("default video model = %q, want seedance", channel.DefaultVideoModel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"framework": "vite",
|
||||
"installCommand": "cd web && bun install",
|
||||
"buildCommand": "cd web && bun run build",
|
||||
"outputDirectory": "web/dist",
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||
}
|
||||
+156
-96
@@ -6,7 +6,6 @@
|
||||
"name": "infinite-canvas",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/nextjs-registry": "^1.3.0",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
@@ -23,10 +22,11 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next": "16.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router": "^7.12.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4",
|
||||
@@ -39,8 +39,10 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5",
|
||||
"vite": "^7.3.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -49,7 +51,7 @@
|
||||
|
||||
"@ant-design/colors": ["@ant-design/colors@8.0.1", "https://registry.npmmirror.com/@ant-design/colors/-/colors-8.0.1.tgz", { "dependencies": { "@ant-design/fast-color": "^3.0.0" } }, "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ=="],
|
||||
|
||||
"@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
|
||||
"@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
||||
|
||||
"@ant-design/cssinjs-utils": ["@ant-design/cssinjs-utils@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs-utils/-/cssinjs-utils-2.1.2.tgz", { "dependencies": { "@ant-design/cssinjs": "^2.1.2", "@babel/runtime": "^7.23.2", "@rc-component/util": "^1.4.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5fTHQ158jJJ5dC/ECeyIdZUzKxE/mpEMRZxthyG1sw/AKRHKgJBg00Yi6ACVXgycdje7KahRNvNET/uBccwCnA=="],
|
||||
|
||||
@@ -59,8 +61,6 @@
|
||||
|
||||
"@ant-design/icons-svg": ["@ant-design/icons-svg@4.4.2", "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", {}, "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA=="],
|
||||
|
||||
"@ant-design/nextjs-registry": ["@ant-design/nextjs-registry@1.3.0", "", { "peerDependencies": { "@ant-design/cssinjs": ">=1.0.0", "antd": ">=5.0.0", "next": ">=14.0.0", "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-lHlg5bnj3LPYHUD+iZsO1k11NhIrx1mLWh2DXQ0m3Ly3OSaYfgeHX0WoVXxdVjEGjjYnPTe+OUY7YvU/p1aPZw=="],
|
||||
|
||||
"@ant-design/pro-components": ["@ant-design/pro-components@3.0.0-beta.3", "https://registry.npmmirror.com/@ant-design/pro-components/-/pro-components-3.0.0-beta.3.tgz", { "dependencies": { "@ant-design/cssinjs": "^1.24.0", "@ant-design/icons": "^5.6.1", "@babel/runtime": "^7.27.6", "@ctrl/tinycolor": "^4.1.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^6.0.1", "@dnd-kit/sortable": "^7.0.2", "@dnd-kit/utilities": "^3.2.2", "@emotion/cache": "^11.14.0", "@emotion/css": "^11.13.5", "@emotion/serialize": "^1.3.3", "@rc-component/util": "^1.2.2", "@umijs/route-utils": "^4.0.1", "@umijs/use-params": "^1.0.9", "classnames": "^2.5.1", "dayjs": "^1.11.13", "lodash-es": "^4.17.21", "path-to-regexp": "^6.3.0", "rc-field-form": "^2.7.0", "rc-footer": "^0.6.8", "rc-resize-observer": "^1.4.3", "rc-steps": "^6.0.1", "rc-table": "^7.51.1", "react-draggable": "^4.5.0", "react-layout-kit": "^1.9.2", "react-lazy-load": "^4.0.1", "react-markdown": "^8.0.7", "react-syntax-highlighter": "^15.6.1", "safe-stable-stringify": "^2.5.0", "shiki-es": "^0.2.0", "swr": "^2.3.4" }, "peerDependencies": { "antd": "^5.11.2", "react": ">=17.0.0", "react-dom": ">=17.0.0" } }, "sha512-ehdIGlDcy4J5kv6E3fZ8e4YLQY4+7S3ZsIZnZ//vtfd+s0Qkv13hQ65CepoMiC/x6gNBlDnHozKANNi7JOtJAw=="],
|
||||
|
||||
"@ant-design/react-slick": ["@ant-design/react-slick@2.0.0", "https://registry.npmmirror.com/@ant-design/react-slick/-/react-slick-2.0.0.tgz", { "dependencies": { "@babel/runtime": "^7.28.4", "clsx": "^2.1.1", "json2mq": "^0.2.0", "throttle-debounce": "^5.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HMS9sRoEmZey8LsE/Yo6+klhlzU12PisjrVcydW3So7RdklyEd2qehyU6a7Yp+OYN72mgsYs3NFCyP2lCPFVqg=="],
|
||||
@@ -89,7 +89,7 @@
|
||||
|
||||
"@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
|
||||
|
||||
"@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="],
|
||||
|
||||
@@ -111,6 +111,10 @@
|
||||
|
||||
"@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "https://registry.npmmirror.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="],
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="],
|
||||
|
||||
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="],
|
||||
|
||||
"@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "https://registry.npmmirror.com/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="],
|
||||
@@ -157,8 +161,6 @@
|
||||
|
||||
"@ecies/ciphers": ["@ecies/ciphers@0.2.6", "https://registry.npmmirror.com/@ecies/ciphers/-/ciphers-0.2.6.tgz", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@emotion/babel-plugin": ["@emotion/babel-plugin@11.13.5", "https://registry.npmmirror.com/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz", { "dependencies": { "@babel/helper-module-imports": "^7.16.7", "@babel/runtime": "^7.18.3", "@emotion/hash": "^0.9.2", "@emotion/memoize": "^0.9.0", "@emotion/serialize": "^1.3.3", "babel-plugin-macros": "^3.1.0", "convert-source-map": "^1.5.0", "escape-string-regexp": "^4.0.0", "find-root": "^1.1.0", "source-map": "^0.5.7", "stylis": "4.2.0" } }, "sha512-pxHCpT2ex+0q+HH91/zsdHkw/lXd468DIN2zvfvLtPKLLMo6gQj7oLObq8PhkrxOZb/gGCq03S3Z7PDhS8pduQ=="],
|
||||
|
||||
"@emotion/cache": ["@emotion/cache@11.14.0", "https://registry.npmmirror.com/@emotion/cache/-/cache-11.14.0.tgz", { "dependencies": { "@emotion/memoize": "^0.9.0", "@emotion/sheet": "^1.4.0", "@emotion/utils": "^1.4.2", "@emotion/weak-memoize": "^0.4.0", "stylis": "4.2.0" } }, "sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA=="],
|
||||
@@ -179,6 +181,58 @@
|
||||
|
||||
"@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "https://registry.npmmirror.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
@@ -189,56 +243,6 @@
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.14", "https://registry.npmmirror.com/@hono/node-server/-/node-server-1.19.14.tgz", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@inquirer/ansi": ["@inquirer/ansi@2.0.5", "https://registry.npmmirror.com/@inquirer/ansi/-/ansi-2.0.5.tgz", {}, "sha512-doc2sWgJpbFQ64UflSVd17ibMGDuxO1yKgOgLMwavzESnXjFWJqUeG8saYosqKpHp4kWiM5x1nXvEjbpx90gzw=="],
|
||||
|
||||
"@inquirer/confirm": ["@inquirer/confirm@6.0.13", "https://registry.npmmirror.com/@inquirer/confirm/-/confirm-6.0.13.tgz", { "dependencies": { "@inquirer/core": "^11.1.10", "@inquirer/type": "^4.0.5" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-wkGPC7yJ5WJk1DJ5SX7fzk+gfj4BM8cf5dDDi71B/551xHrdsZVRJOC0WyikXd0pEsb/9cLniuE4atbsMqmFkw=="],
|
||||
@@ -273,24 +277,6 @@
|
||||
|
||||
"@mswjs/interceptors": ["@mswjs/interceptors@0.41.9", "https://registry.npmmirror.com/@mswjs/interceptors/-/interceptors-0.41.9.tgz", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.3", "", {}, "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.3", "", { "os": "win32", "cpu": "x64" }, "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@1.3.0", "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-1.3.0.tgz", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="],
|
||||
|
||||
"@noble/curves": ["@noble/curves@1.9.7", "https://registry.npmmirror.com/@noble/curves/-/curves-1.9.7.tgz", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="],
|
||||
@@ -513,12 +499,62 @@
|
||||
|
||||
"@rc-component/virtual-list": ["@rc-component/virtual-list@1.0.2", "https://registry.npmmirror.com/@rc-component/virtual-list/-/virtual-list-1.0.2.tgz", { "dependencies": { "@babel/runtime": "^7.20.0", "@rc-component/resize-observer": "^1.0.1", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.9.0", "react-dom": ">=16.9.0" } }, "sha512-uvTol/mH74FYsn5loDGJxo+7kjkO4i+y4j87Re1pxJBs0FaeuMuLRzQRGaXwnMcV1CxpZLi2Z56Rerj2M00fjQ=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "https://registry.npmmirror.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
"@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "https://registry.npmmirror.com/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.4", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.4", "@tailwindcss/oxide-darwin-arm64": "4.2.4", "@tailwindcss/oxide-darwin-x64": "4.2.4", "@tailwindcss/oxide-freebsd-x64": "4.2.4", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", "@tailwindcss/oxide-linux-x64-musl": "4.2.4", "@tailwindcss/oxide-wasm32-wasi": "4.2.4", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q=="],
|
||||
@@ -555,8 +591,18 @@
|
||||
|
||||
"@ts-morph/common": ["@ts-morph/common@0.27.0", "https://registry.npmmirror.com/@ts-morph/common/-/common-0.27.0.tgz", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="],
|
||||
|
||||
"@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="],
|
||||
|
||||
"@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/file-saver": ["@types/file-saver@2.0.7", "", {}, "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="],
|
||||
|
||||
"@types/hast": ["@types/hast@2.3.10", "https://registry.npmmirror.com/@types/hast/-/hast-2.3.10.tgz", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
|
||||
@@ -591,6 +637,8 @@
|
||||
|
||||
"@umijs/use-params": ["@umijs/use-params@1.0.9", "https://registry.npmmirror.com/@umijs/use-params/-/use-params-1.0.9.tgz", { "peerDependencies": { "react": "*" } }, "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "https://registry.npmmirror.com/agent-base/-/agent-base-7.1.4.tgz", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
@@ -661,8 +709,6 @@
|
||||
|
||||
"cli-width": ["cli-width@4.1.0", "https://registry.npmmirror.com/cli-width/-/cli-width-4.1.0.tgz", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
@@ -765,6 +811,8 @@
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
@@ -835,6 +883,8 @@
|
||||
|
||||
"fs-extra": ["fs-extra@11.3.5", "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.5.tgz", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"fuzzysort": ["fuzzysort@3.1.0", "https://registry.npmmirror.com/fuzzysort/-/fuzzysort-3.1.0.tgz", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
|
||||
@@ -1115,8 +1165,6 @@
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"next": ["next@16.2.3", "", { "dependencies": { "@next/env": "16.2.3", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.3", "@next/swc-darwin-x64": "16.2.3", "@next/swc-linux-arm64-gnu": "16.2.3", "@next/swc-linux-arm64-musl": "16.2.3", "@next/swc-linux-x64-gnu": "16.2.3", "@next/swc-linux-x64-musl": "16.2.3", "@next/swc-win32-arm64-msvc": "16.2.3", "@next/swc-win32-x64-msvc": "16.2.3", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
@@ -1229,10 +1277,16 @@
|
||||
|
||||
"react-markdown": ["react-markdown@8.0.7", "https://registry.npmmirror.com/react-markdown/-/react-markdown-8.0.7.tgz", { "dependencies": { "@types/hast": "^2.0.0", "@types/prop-types": "^15.0.0", "@types/unist": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^2.0.0", "prop-types": "^15.0.0", "property-information": "^6.0.0", "react-is": "^18.0.0", "remark-parse": "^10.0.0", "remark-rehype": "^10.0.0", "space-separated-tokens": "^2.0.0", "style-to-object": "^0.4.0", "unified": "^10.0.0", "unist-util-visit": "^4.0.0", "vfile": "^5.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-router": ["react-router@7.18.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ=="],
|
||||
|
||||
"react-router-dom": ["react-router-dom@7.18.0", "", { "dependencies": { "react-router": "7.18.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"react-syntax-highlighter": ["react-syntax-highlighter@15.6.6", "https://registry.npmmirror.com/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz", { "dependencies": { "@babel/runtime": "^7.3.1", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^3.6.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw=="],
|
||||
@@ -1261,6 +1315,8 @@
|
||||
|
||||
"reusify": ["reusify@1.1.0", "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="],
|
||||
|
||||
"router": ["router@2.2.0", "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"run-applescript": ["run-applescript@7.1.0", "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="],
|
||||
@@ -1277,20 +1333,18 @@
|
||||
|
||||
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
"semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"send": ["send@1.2.1", "https://registry.npmmirror.com/send/-/send-1.2.1.tgz", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "https://registry.npmmirror.com/serve-static/-/serve-static-2.2.1.tgz", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shadcn": ["shadcn@4.7.0", "https://registry.npmmirror.com/shadcn/-/shadcn-4.7.0.tgz", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-70fwnesNrY1GgeD7Kdzn+3SsYeyfibm8immsA5L68+OusoPTvYF01oWExl8/latKpMpvVXcbgdbbE6VFBJQ38w=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
@@ -1337,8 +1391,6 @@
|
||||
|
||||
"style-to-object": ["style-to-object@0.4.4", "https://registry.npmmirror.com/style-to-object/-/style-to-object-0.4.4.tgz", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"stylis": ["stylis@4.4.0", "https://registry.npmmirror.com/stylis/-/stylis-4.4.0.tgz", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
@@ -1357,6 +1409,8 @@
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"tldts": ["tldts@7.0.30", "https://registry.npmmirror.com/tldts/-/tldts-7.0.30.tgz", { "dependencies": { "tldts-core": "^7.0.30" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw=="],
|
||||
|
||||
"tldts-core": ["tldts-core@7.0.30", "https://registry.npmmirror.com/tldts-core/-/tldts-core-7.0.30.tgz", {}, "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q=="],
|
||||
@@ -1429,6 +1483,8 @@
|
||||
|
||||
"vfile-message": ["vfile-message@3.1.4", "https://registry.npmmirror.com/vfile-message/-/vfile-message-3.1.4.tgz", { "dependencies": { "@types/unist": "^2.0.0", "unist-util-stringify-position": "^3.0.0" } }, "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw=="],
|
||||
|
||||
"vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
@@ -1463,15 +1519,19 @@
|
||||
|
||||
"zustand": ["zustand@5.0.12", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g=="],
|
||||
|
||||
"@ant-design/pro-components/@ant-design/cssinjs": ["@ant-design/cssinjs@1.24.0", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "classnames": "^2.3.1", "csstype": "^3.1.3", "rc-util": "^5.35.0", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg=="],
|
||||
"@ant-design/cssinjs-utils/@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
|
||||
|
||||
"@ant-design/pro-components/@ant-design/icons": ["@ant-design/icons@5.6.1", "https://registry.npmmirror.com/@ant-design/icons/-/icons-5.6.1.tgz", { "dependencies": { "@ant-design/colors": "^7.0.0", "@ant-design/icons-svg": "^4.4.0", "@babel/runtime": "^7.24.8", "classnames": "^2.2.6", "rc-util": "^5.31.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
"@babel/plugin-syntax-jsx/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
"@babel/plugin-syntax-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
"@babel/plugin-transform-modules-commonjs/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/plugin-transform-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/preset-typescript/@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@dotenvx/dotenvx/commander": ["commander@11.1.0", "https://registry.npmmirror.com/commander/-/commander-11.1.0.tgz", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="],
|
||||
|
||||
@@ -1517,6 +1577,8 @@
|
||||
|
||||
"accepts/mime-types": ["mime-types@3.0.2", "https://registry.npmmirror.com/mime-types/-/mime-types-3.0.2.tgz", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"antd/@ant-design/cssinjs": ["@ant-design/cssinjs@2.1.2", "https://registry.npmmirror.com/@ant-design/cssinjs/-/cssinjs-2.1.2.tgz", { "dependencies": { "@babel/runtime": "^7.11.1", "@emotion/hash": "^0.8.0", "@emotion/unitless": "^0.7.5", "@rc-component/util": "^1.4.0", "clsx": "^2.1.1", "csstype": "^3.1.3", "stylis": "^4.3.4" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-2Hy8BnCEH31xPeSLbhhB2ctCPXE2ZnASdi+KbSeS79BNbUhL9hAEe20SkUk+BR8aKTmqb6+FKFruk7w8z0VoRQ=="],
|
||||
|
||||
"antd/@ant-design/icons": ["@ant-design/icons@6.2.3", "https://registry.npmmirror.com/@ant-design/icons/-/icons-6.2.3.tgz", { "dependencies": { "@ant-design/colors": "^8.0.1", "@ant-design/icons-svg": "^4.4.2", "@rc-component/util": "^1.10.1", "clsx": "^2.1.1" }, "peerDependencies": { "react": ">=16.0.0", "react-dom": ">=16.0.0" } }, "sha512-Pl3aoAtxQeKryYnt6VvDJtOxMOtA8wrRSACe/pTjOAIG3fdHrWm6Ivb4ku9tsFjYroSXBKirvuxG4QkwBXD9gg=="],
|
||||
|
||||
"babel-plugin-macros/cosmiconfig": ["cosmiconfig@7.1.0", "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-7.1.0.tgz", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="],
|
||||
@@ -1539,12 +1601,12 @@
|
||||
|
||||
"hastscript/space-separated-tokens": ["space-separated-tokens@1.1.5", "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", {}, "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA=="],
|
||||
|
||||
"headers-polyfill/set-cookie-parser": ["set-cookie-parser@3.1.0", "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||
|
||||
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "https://registry.npmmirror.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
|
||||
|
||||
"micromatch/picomatch": ["picomatch@2.3.2", "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"npm-run-path/path-key": ["path-key@4.0.0", "https://registry.npmmirror.com/path-key/-/path-key-4.0.0.tgz", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||
|
||||
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
@@ -1597,8 +1659,6 @@
|
||||
|
||||
"express/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"send/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"type-is/mime-types/mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="font-sans">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="一个无限画布创作工具" />
|
||||
<link rel="icon" href="/logo.svg" />
|
||||
<title>无限画布</title>
|
||||
<script>
|
||||
try {
|
||||
var s = JSON.parse(localStorage.getItem("infinite-canvas:theme_store") || "{}");
|
||||
var t = s.state && s.state.theme === "light" ? "light" : "dark";
|
||||
document.documentElement.classList.toggle("dark", t === "dark");
|
||||
document.documentElement.style.colorScheme = t;
|
||||
} catch (e) {}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-background text-foreground antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Vendored
-6
@@ -1,6 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
import { PHASE_DEVELOPMENT_SERVER } from "next/constants";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { parseChangelog } from "@/lib/release";
|
||||
|
||||
const webDir = dirname(fileURLToPath(import.meta.url));
|
||||
const localVersion = readFileSync(resolve(webDir, "../VERSION"), "utf8").trim() || "dev";
|
||||
const localChangelog = readFileSync(resolve(webDir, "../CHANGELOG.md"), "utf8");
|
||||
|
||||
export default function nextConfig(phase: string): NextConfig {
|
||||
const isDev = phase === PHASE_DEVELOPMENT_SERVER;
|
||||
const releases = parseChangelog(localChangelog);
|
||||
|
||||
return {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
env: {
|
||||
NEXT_PUBLIC_APP_VERSION: localVersion,
|
||||
NEXT_PUBLIC_APP_RELEASES: JSON.stringify(releases),
|
||||
},
|
||||
};
|
||||
}
|
||||
Generated
+18030
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -4,15 +4,15 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack -H 0.0.0.0 -p 3000",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev": "vite --host 0.0.0.0 --port 3000",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "vite preview --host 0.0.0.0 --port 3000",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.1.1",
|
||||
"@ant-design/nextjs-registry": "^1.3.0",
|
||||
"@ant-design/pro-components": "3.0.0-beta.3",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@tanstack/react-query": "^5.100.9",
|
||||
@@ -29,11 +29,13 @@
|
||||
"lucide-react": "^1.16.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.11",
|
||||
"next": "16.2.3",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"react-router": "^7.12.0",
|
||||
"react-router-dom": "^7.12.0",
|
||||
"shadcn": "^4.7.0",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -45,7 +47,9 @@
|
||||
"@types/node": "^20",
|
||||
"@types/react": "19.1.12",
|
||||
"@types/react-dom": "19.1.9",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vite": "^7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ProTable, type ProColumns } from "@ant-design/pro-components";
|
||||
import { Button, Card, Col, Flex, Form, Image, Input, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import type { AdminAsset } from "@/services/api/admin";
|
||||
import { useAdminAssets } from "./use-admin-assets";
|
||||
|
||||
type AssetFormValues = Partial<AdminAsset> & { tagText?: string };
|
||||
|
||||
const typeOptions = [
|
||||
{ label: "全部类型", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
];
|
||||
|
||||
const editTypeOptions = typeOptions.slice(1);
|
||||
|
||||
export default function AdminAssetsPage() {
|
||||
const { assets, tags, keyword, kind, tag, page, pageSize, total, isLoading, searchAssets, changeKind, changeTag, changePage, changePageSize, resetFilters, refreshAssets, saveAsset: saveAdminAsset, deleteAsset } = useAdminAssets();
|
||||
const copyText = useCopyText();
|
||||
const [form] = Form.useForm<AssetFormValues>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingAsset, setEditingAsset] = useState<Partial<AdminAsset> | null>(null);
|
||||
const [detailAsset, setDetailAsset] = useState<AdminAsset | null>(null);
|
||||
const [deletingAsset, setDeletingAsset] = useState<AdminAsset | null>(null);
|
||||
const formType = Form.useWatch("type", form) || editingAsset?.type || "text";
|
||||
const tagOptions = tags.map((item) => ({ label: item, value: item }));
|
||||
|
||||
useEffect(() => {
|
||||
if (editingAsset) form.setFieldsValue({ ...editingAsset, tagText: editingAsset.tags?.join(", ") || "" });
|
||||
}, [editingAsset, form]);
|
||||
|
||||
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||
|
||||
const saveAsset = async () => {
|
||||
const value = await form.validateFields();
|
||||
const nextType = value.type || "text";
|
||||
await saveAdminAsset({
|
||||
...editingAsset,
|
||||
...value,
|
||||
type: nextType,
|
||||
coverUrl: value.coverUrl || (nextType === "image" ? value.url : ""),
|
||||
tags: (value.tagText || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
setEditingAsset(null);
|
||||
};
|
||||
|
||||
const columns: ProColumns<AdminAsset>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
dataIndex: "coverUrl",
|
||||
width: 88,
|
||||
render: (_, item) => <Image src={item.coverUrl || item.url || "/logo.svg"} alt={item.title} width={56} height={42} style={{ objectFit: "cover", borderRadius: 6 }} preview={{ mask: "放大" }} fallback="/logo.svg" />,
|
||||
},
|
||||
{
|
||||
title: "标题",
|
||||
dataIndex: "title",
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<Typography.Link strong ellipsis style={{ maxWidth: 260, display: "block" }} onClick={() => setDetailAsset(item)}>
|
||||
{item.title}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 84,
|
||||
render: (_, item) => <Tag>{item.type === "image" ? "图片" : "文本"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "标签",
|
||||
dataIndex: "tags",
|
||||
width: 180,
|
||||
render: (_, item) => (
|
||||
<Space size={[4, 4]} wrap>
|
||||
{(item.tags || []).slice(0, 3).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
width: 120,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.category || "未标注"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情">
|
||||
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailAsset(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingAsset(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingAsset(item)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keywordText} placeholder="搜索标题、内容或标签" allowClear enterButton={<SearchOutlined />} onSearch={() => searchAssets(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="180px">
|
||||
<Form.Item label="类型">
|
||||
<Select value={kind} onChange={changeKind} options={typeOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="标签">
|
||||
<Select mode="multiple" allowClear maxTagCount="responsive" value={tag} onChange={changeTag} options={tagOptions} placeholder="全部标签" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchAssets(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<AdminAsset>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={assets}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>素材列表</Typography.Text>
|
||||
<Tag>{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshAssets() }}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingAsset({ type: "text", tags: [] })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<Modal title={editingAsset?.id ? "编辑素材" : "新增素材"} open={Boolean(editingAsset)} width={760} onCancel={() => setEditingAsset(null)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请选择类型" }]}>
|
||||
<Select options={editTypeOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="描述">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
{formType === "image" ? (
|
||||
<Form.Item name="url" label="图片 URL" rules={[{ required: true, message: "请输入图片 URL" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="content" label="文本内容" rules={[{ required: true, message: "请输入文本内容" }]}>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="素材详情" open={Boolean(detailAsset)} width={760} onCancel={() => setDetailAsset(null)} footer={<Button onClick={() => setDetailAsset(null)}>关闭</Button>}>
|
||||
{detailAsset ? (
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={14} align="start">
|
||||
<Image src={detailAsset.coverUrl || detailAsset.url || "/logo.svg"} alt={detailAsset.title} width={116} height={84} style={{ objectFit: "cover", borderRadius: 8 }} preview={{ mask: "放大" }} fallback="/logo.svg" />
|
||||
<Flex vertical gap={8} style={{ minWidth: 0 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{detailAsset.title}
|
||||
</Typography.Title>
|
||||
<Space wrap>
|
||||
<Tag>{detailAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||||
{detailAsset.category ? <Tag>{detailAsset.category}</Tag> : null}
|
||||
{(detailAsset.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{detailAsset.description ? (
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
{detailAsset.description}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Input.TextArea value={detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content} rows={7} readOnly />
|
||||
<Button icon={<CopyOutlined />} onClick={() => copyText(detailAsset.type === "image" ? detailAsset.url || detailAsset.coverUrl : detailAsset.content)}>
|
||||
复制内容
|
||||
</Button>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除素材"
|
||||
open={Boolean(deletingAsset)}
|
||||
onCancel={() => setDeletingAsset(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingAsset) return;
|
||||
await deleteAsset(deletingAsset.id);
|
||||
setDeletingAsset(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除「{deletingAsset?.title}」吗?删除后会从服务器素材库中移除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { deleteAdminAsset, fetchAdminAssets, saveAdminAsset, type AdminAsset } from "@/services/api/admin";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const defaultPageSize = 10;
|
||||
|
||||
export function useAdminAssets() {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const clearSession = useUserStore((state) => state.clearSession);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [type, setType] = useState("");
|
||||
const [tag, setTag] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "assets", token, keyword, type, tag, page, pageSize],
|
||||
queryFn: () => fetchAdminAssets(token, { keyword, type, tag, page, pageSize }),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (asset: Partial<AdminAsset>) => saveAdminAsset(token, asset),
|
||||
onSuccess: async (_, asset) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
|
||||
message.success(asset.id ? "素材已保存" : "素材已新增");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "保存失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminAsset(token, id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "assets"] });
|
||||
message.success("素材已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
const errorMessage = query.error instanceof Error ? query.error.message : "读取素材失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
|
||||
}
|
||||
}, [clearSession, message, query.error, query.isError]);
|
||||
|
||||
const updateFilters = (next: Partial<{ keyword: string; type: string; tag: string[]; page: number; pageSize: number }>) => {
|
||||
const queryState = { keyword, type, tag, page, pageSize, ...next };
|
||||
if (next.keyword !== undefined || next.type !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
|
||||
setKeyword(queryState.keyword);
|
||||
setType(queryState.type);
|
||||
setTag(queryState.tag);
|
||||
setPage(queryState.page);
|
||||
setPageSize(queryState.pageSize);
|
||||
};
|
||||
|
||||
const data = query.data;
|
||||
|
||||
return {
|
||||
assets: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
kind: type,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
||||
searchAssets: (value = keyword) => updateFilters({ keyword: value }),
|
||||
changeKind: (value: string) => updateFilters({ type: value, tag: [] }),
|
||||
changeTag: (value: string[]) => updateFilters({ tag: value }),
|
||||
changePage: (value: number) => updateFilters({ page: value }),
|
||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||
resetFilters: () => updateFilters({ keyword: "", type: "", tag: [], page: 1, pageSize: defaultPageSize }),
|
||||
refreshAssets: () => query.refetch(),
|
||||
saveAsset: (asset: Partial<AdminAsset>) => saveMutation.mutateAsync(asset),
|
||||
deleteAsset: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ProTable, type ProColumns } from "@ant-design/pro-components";
|
||||
import { Button, Card, Col, Form, Input, InputNumber, Modal, Row, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { AdminCreditLog } from "@/services/api/admin";
|
||||
import { useAdminCreditLogs } from "./use-admin-credit-logs";
|
||||
|
||||
type CreditLogFormValues = Partial<AdminCreditLog>;
|
||||
|
||||
const creditLogTypeLabels: Record<string, string> = {
|
||||
admin_adjust: "后台调整",
|
||||
ai_consume: "模型消费",
|
||||
ai_refund: "失败返还",
|
||||
};
|
||||
|
||||
export default function AdminCreditLogsPage() {
|
||||
const { logs, keyword, page, pageSize, total, isLoading, searchLogs, changePage, changePageSize, resetFilters, refreshLogs, saveLog: saveAdminLog, deleteLog } = useAdminCreditLogs();
|
||||
const [form] = Form.useForm<CreditLogFormValues>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingLog, setEditingLog] = useState<Partial<AdminCreditLog> | null>(null);
|
||||
const [deletingLog, setDeletingLog] = useState<AdminCreditLog | null>(null);
|
||||
|
||||
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingLog) form.setFieldsValue({ type: "admin_adjust", amount: 0, balance: 0, ...editingLog });
|
||||
}, [editingLog, form]);
|
||||
|
||||
const saveLog = async () => {
|
||||
const value = await form.validateFields();
|
||||
await saveAdminLog({ ...editingLog, ...value });
|
||||
setEditingLog(null);
|
||||
};
|
||||
|
||||
const columns: ProColumns<AdminCreditLog>[] = [
|
||||
{
|
||||
title: "用户 ID",
|
||||
dataIndex: "userId",
|
||||
width: 220,
|
||||
render: (_, item) => <Typography.Text copyable>{item.userId}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "类型",
|
||||
dataIndex: "type",
|
||||
width: 140,
|
||||
render: (_, item) => <Tag>{creditLogTypeLabels[item.type] || item.type || "-"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "变动",
|
||||
dataIndex: "amount",
|
||||
width: 100,
|
||||
render: (_, item) => <Typography.Text type={item.amount >= 0 ? "success" : "danger"}>{item.amount}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "余额",
|
||||
dataIndex: "balance",
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
title: "备注",
|
||||
dataIndex: "remark",
|
||||
ellipsis: true,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.remark || "-"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "创建时间",
|
||||
dataIndex: "createdAt",
|
||||
width: 180,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.createdAt ? dayjs(item.createdAt).format("YYYY-MM-DD HH:mm:ss") : "-"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 96,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingLog(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingLog(item)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card variant="borderless">
|
||||
<Form layout="vertical">
|
||||
<Row gutter={16} align="bottom">
|
||||
<Col flex="360px">
|
||||
<Form.Item label="关键词">
|
||||
<Input.Search value={keywordText} placeholder="搜索用户 ID、类型、备注或关联 ID" allowClear enterButton={<SearchOutlined />} onSearch={() => searchLogs(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="none">
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setKeywordText("");
|
||||
resetFilters();
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button type="primary" icon={<ReloadOutlined />} onClick={() => searchLogs(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<AdminCreditLog>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={logs}
|
||||
loading={isLoading}
|
||||
search={false}
|
||||
defaultSize="middle"
|
||||
tableLayout="fixed"
|
||||
cardProps={{ variant: "borderless" }}
|
||||
headerTitle={
|
||||
<Space>
|
||||
<Typography.Text strong>算力点日志</Typography.Text>
|
||||
<Tag>{total} 条</Tag>
|
||||
</Space>
|
||||
}
|
||||
options={{ density: true, setting: true, reload: () => void refreshLogs() }}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingLog({ type: "admin_adjust", amount: 0, balance: 0 })}>
|
||||
新增
|
||||
</Button>,
|
||||
]}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (value) => `共 ${value} 条`,
|
||||
onChange: (nextPage, nextPageSize) => (nextPageSize !== pageSize ? changePageSize(nextPageSize) : changePage(nextPage)),
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Modal title={editingLog?.id ? "编辑日志" : "新增日志"} open={Boolean(editingLog)} width={680} onCancel={() => setEditingLog(null)} onOk={() => void saveLog()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Row gutter={14}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="userId" label="用户 ID" rules={[{ required: true, message: "请输入用户 ID" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="type" label="类型" rules={[{ required: true, message: "请输入类型" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="amount" label="变动数量" rules={[{ required: true, message: "请输入变动数量" }]}>
|
||||
<InputNumber precision={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="balance" label="变动后余额" rules={[{ required: true, message: "请输入变动后余额" }]}>
|
||||
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="relatedId" label="关联 ID">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="createdAt" label="创建时间">
|
||||
<Input placeholder="不填则新增时自动生成" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="extra" label="扩展信息">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除日志"
|
||||
open={Boolean(deletingLog)}
|
||||
onCancel={() => setDeletingLog(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingLog) return;
|
||||
await deleteLog(deletingLog.id);
|
||||
setDeletingLog(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除这条算力点日志吗?
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user