mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 973d14f4c3 | |||
| f038406070 | |||
| 1b289f6a77 | |||
| 27bd6f004b | |||
| 7893bd72dd | |||
| f7b030e209 | |||
| bce9c2d897 | |||
| b4515ed85f | |||
| 7f0199da59 | |||
| 8a66524ea7 | |||
| 0d90465339 | |||
| d3923e21e0 | |||
| b1164f9c12 | |||
| b6d358d6f8 | |||
| e422285c59 | |||
| 8aa3fad684 | |||
| 22f256d1be | |||
| 8d0b5ecdb8 | |||
| 373229a04a | |||
| 39a1e859b1 | |||
| dd9347e85d | |||
| 13418a4d26 | |||
| 506378c308 | |||
| 81b9a207b9 | |||
| 3667557dc2 | |||
| eb050fc08a | |||
| 72024c8138 |
@@ -1,29 +1 @@
|
||||
# 管理员账号,首次启动会自动创建。
|
||||
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
|
||||
|
||||
@@ -9,9 +9,37 @@ permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/basketikun/infinite-canvas
|
||||
|
||||
jobs:
|
||||
build:
|
||||
meta:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
steps:
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=
|
||||
|
||||
build:
|
||||
needs: meta
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,19 +51,65 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
- id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ needs.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=app-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=app-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "$RUNNER_TEMP/digests/${digest#sha256:}"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: digests-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs:
|
||||
- meta
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create multi-arch manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ needs.meta.outputs.tags }}
|
||||
run: |
|
||||
tag_args=()
|
||||
while IFS= read -r tag; do
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args+=("-t" "$tag")
|
||||
fi
|
||||
done <<< "$TAGS"
|
||||
|
||||
digest_args=()
|
||||
while IFS= read -r digest_file; do
|
||||
digest_args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done < <(find "$RUNNER_TEMP/digests" -type f -printf '%f\n' | sort)
|
||||
|
||||
docker buildx imagetools create "${tag_args[@]}" "${digest_args[@]}"
|
||||
|
||||
@@ -9,9 +9,38 @@ permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/basketikun/infinite-canvas-docs
|
||||
|
||||
jobs:
|
||||
build:
|
||||
meta:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
steps:
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=
|
||||
|
||||
build:
|
||||
needs: meta
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
arch: amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -23,21 +52,66 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}-docs
|
||||
tags: |
|
||||
type=raw,value=latest
|
||||
type=ref,event=tag
|
||||
type=sha,prefix=
|
||||
|
||||
- uses: docker/build-push-action@v6
|
||||
- id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docs/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha,scope=docs
|
||||
cache-to: type=gha,mode=max,scope=docs
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ needs.meta.outputs.labels }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docs-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=docs-${{ matrix.arch }}
|
||||
|
||||
- name: Export digest
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/digests"
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
touch "$RUNNER_TEMP/digests/${digest#sha256:}"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: docs-digests-${{ matrix.arch }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs:
|
||||
- meta
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: ${{ runner.temp }}/digests
|
||||
pattern: docs-digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create multi-arch manifest
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ needs.meta.outputs.tags }}
|
||||
run: |
|
||||
tag_args=()
|
||||
while IFS= read -r tag; do
|
||||
if [ -n "$tag" ]; then
|
||||
tag_args+=("-t" "$tag")
|
||||
fi
|
||||
done <<< "$TAGS"
|
||||
|
||||
digest_args=()
|
||||
while IFS= read -r digest_file; do
|
||||
digest_args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done < <(find "$RUNNER_TEMP/digests" -type f -printf '%f\n' | sort)
|
||||
|
||||
docker buildx imagetools create "${tag_args[@]}" "${digest_args[@]}"
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
name: Publish Canvas Agent
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "canvas-agent/**"
|
||||
- ".github/workflows/publish-canvas-agent.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: publish-canvas-agent
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Check package version
|
||||
id: package
|
||||
run: |
|
||||
name=$(node -p "require('./package.json').name")
|
||||
version=$(node -p "require('./package.json').version")
|
||||
published=$(npm view "$name@$version" version 2>/dev/null || true)
|
||||
echo "name=$name" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$published" ]; then
|
||||
echo "should_publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "should_publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.package.outputs.should_publish == 'true'
|
||||
run: npm publish --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Skip existing version
|
||||
if: steps.package.outputs.should_publish != 'true'
|
||||
run: echo "${{ steps.package.outputs.name }}@${{ steps.package.outputs.version }} already exists on npm, skipping publish."
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.4.0 - 2026-06-16
|
||||
|
||||
+ [新增] 新增网页版Agent Loop模式。
|
||||
+ [新增] 支持Vercel一键部署。
|
||||
+ [调整] 移除后端,项目定位为个人画布工具。
|
||||
|
||||
## v0.3.0 - 2026-06-15
|
||||
|
||||
+ [新增] 新增canvas-agent通过codex操作画布。
|
||||
|
||||
## v0.2.5 - 2026-06-08
|
||||
|
||||
+ [新增] 新增图片切图功能。
|
||||
+ [新增] 支持webdav同步数据。
|
||||
+ [修复] 修复画布文字节点错误问题。
|
||||
|
||||
## v0.2.4 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片反推提示词功能。
|
||||
|
||||
@@ -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`.
|
||||
+2
-21
@@ -9,38 +9,19 @@ COPY CHANGELOG.md /app/CHANGELOG.md
|
||||
COPY web ./
|
||||
RUN bun run build
|
||||
|
||||
# 构建 Go 后端入口。
|
||||
FROM golang:1.25-alpine AS api-build
|
||||
|
||||
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。
|
||||
# 运行镜像:只启动 Next.js,AI 请求由浏览器前台直连用户自己的接口。
|
||||
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
|
||||
|
||||
EXPOSE 3000
|
||||
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
|
||||
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && PORT=3000 node server.js"]
|
||||
CMD ["sh", "-c", "cd /app/web && PORT=3000 node server.js"]
|
||||
|
||||
@@ -10,9 +10,8 @@
|
||||
<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="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://vercel.com/"><img src="https://img.shields.io/badge/Vercel-ready-000000?style=flat-square&logo=vercel" alt="Vercel ready"></a>
|
||||
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/Next.js-16.2-000000?style=flat-square&logo=nextdotjs" alt="Next.js"></a>
|
||||
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.25-00add8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
</p>
|
||||
|
||||
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
|
||||
@@ -25,53 +24,54 @@
|
||||
## 核心功能
|
||||
|
||||
- 无限画布:多画布项目、节点拖拽缩放、连线、小地图、撤销重做、导入导出。
|
||||
- AI 创作:支持 OpenAI 兼容接口的文生图、图生图、参考图编辑、文本问答和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- AI 创作:浏览器前台直连你配置的 OpenAI 兼容接口,支持文生图、图生图、参考图编辑、文本问答、音频和视频生成;Seedance 2.0 可通过火山方舟 Agent Plan 接入。
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
|
||||
- 本地 Agent:通过本机 Canvas Agent 连接 Codex / Claude Code,让 Agent 通过 MCP 操作当前画布。
|
||||
- 提示词库:Next.js route 抓取多个 GitHub 开源项目,并缓存在运行实例内存中。
|
||||
|
||||
完整功能说明见 [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。
|
||||
- 少量 Next.js Route:第三方提示词内存缓存、WebDAV 可选代理。
|
||||
- 部署:Vercel 或 Docker。
|
||||
|
||||
## 快速开始
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas)
|
||||
推荐直接导入仓库到 Vercel,根目录已提供 `vercel.json`,会构建 `web/`。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
|
||||
docker build -t infinite-canvas .
|
||||
docker run --rm -p 3000:3000 infinite-canvas
|
||||
```
|
||||
|
||||
运行后默认端口3000,可访问 `http://localhost:3000`。
|
||||
|
||||
如需要拉取提示词,可前往:`http://localhost:3000/admin/prompts`
|
||||
首次打开后进入右上角配置,填入自己的 OpenAI 兼容 `Base URL` 和 `API Key`。
|
||||
|
||||
## New API 自动配置
|
||||
|
||||
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
|
||||
|
||||
```text
|
||||
https://infinite-canvas-cpco.onrender.com?apiKey={key}&baseUrl={address}
|
||||
https://canvas.best?apiKey={key}&baseUrl={address}
|
||||
```
|
||||
|
||||
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
|
||||
如果自己部署了,可以把 `https://infinite-canvas-cpco.onrender.com` 替换成你部署的地址。
|
||||
如果自己部署了,可以把 `https://canvas.best` 替换成你部署的地址。
|
||||
|
||||
## 效果展示
|
||||
|
||||
@@ -88,18 +88,24 @@ https://infinite-canvas-cpco.onrender.com?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)
|
||||
- [快速开始](docs/content/docs/overview/quick-start.mdx)
|
||||
- [功能介绍](docs/content/docs/overview/features.mdx)
|
||||
- [Render 部署](docs/content/docs/overview/render.mdx)
|
||||
- [Docker 部署](docs/content/docs/overview/docker.mdx)
|
||||
- [画布节点操作手册](docs/content/docs/canvas/canvas-node-manual.mdx)
|
||||
- [画布快捷键](docs/content/docs/canvas/canvas-shortcuts.mdx)
|
||||
- [贡献者协议](CLA.md)
|
||||
- [漏洞提交](SECURITY.md)
|
||||
- [待办事项](docs/content/docs/progress/todo.mdx)
|
||||
- [本地 Canvas Agent](canvas-agent/README.md)
|
||||
|
||||
## 赞助支持
|
||||
|
||||
|
||||
+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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -0,0 +1,113 @@
|
||||
# Infinite Canvas Agent
|
||||
|
||||
本地 Canvas Agent 用来连接线上画布网页和用户电脑上的 Codex / Claude Code。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
npx -y @basketikun/canvas-agent
|
||||
```
|
||||
|
||||
本仓库开发时也可以直接运行:
|
||||
|
||||
```bash
|
||||
cd canvas-agent
|
||||
npm install
|
||||
npm run build
|
||||
node dist/index.js
|
||||
```
|
||||
|
||||
启动后会输出本机地址和 token:
|
||||
|
||||
```txt
|
||||
Local URL: http://127.0.0.1:17371
|
||||
Connect token: xxxxxx
|
||||
```
|
||||
|
||||
在画布右上角点击 `Agent`,填入地址和 token 后连接。
|
||||
|
||||
Canvas Agent 默认只监听 `127.0.0.1`。网页第一次带正确 token 连接后,Canvas Agent 会记录该网页 Origin;之后其他 Origin 不能复用这个本地 Agent,除非用户清理 `~/.infinite-canvas/canvas-agent.json` 里的 `origins`。
|
||||
|
||||
## 发布
|
||||
|
||||
`canvas-agent` 使用自己的 `package.json` 版本号,不跟仓库根目录 `VERSION` 绑定。推送到 `main` 后,GitHub Actions 会检查 npm 上是否已经存在当前包版本;不存在时才发布 `@basketikun/canvas-agent`。
|
||||
|
||||
发布前需要在 GitHub 仓库 Secrets 中配置 `NPM_TOKEN`。
|
||||
|
||||
## Codex MCP
|
||||
|
||||
如果希望 Codex 终端能直接操作画布,需要先把 Canvas Agent 注册成 Codex MCP。
|
||||
|
||||
Canvas Agent 启动后,给 Codex 添加 MCP:
|
||||
|
||||
```bash
|
||||
codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp
|
||||
```
|
||||
|
||||
本仓库开发时可以改成,实际使用建议替换为本机绝对路径:
|
||||
|
||||
```bash
|
||||
codex mcp add infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp
|
||||
```
|
||||
|
||||
Canvas Agent 源码使用 TypeScript 编写,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod` 描述。
|
||||
|
||||
如果希望终端里的 Codex 不被 MCP 审批卡住,可以在 `~/.codex/config.toml` 里给这个 MCP 设置自动放行:
|
||||
|
||||
```toml
|
||||
[mcp_servers.infinite-canvas]
|
||||
command = "npx"
|
||||
args = ["-y", "@basketikun/canvas-agent", "mcp"]
|
||||
default_tools_approval_mode = "approve"
|
||||
```
|
||||
|
||||
可用工具:
|
||||
|
||||
- `canvas_get_state`
|
||||
- `canvas_get_selection`
|
||||
- `canvas_export_snapshot`
|
||||
- `canvas_apply_ops`
|
||||
- `canvas_create_text_node`
|
||||
- `canvas_create_image_prompt_flow`
|
||||
|
||||
`canvas_apply_ops` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"ops": [
|
||||
{
|
||||
"type": "add_node",
|
||||
"nodeType": "text",
|
||||
"title": "标题",
|
||||
"position": { "x": 0, "y": 0 },
|
||||
"metadata": { "content": "文本内容" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 侧边栏 Codex
|
||||
|
||||
本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
|
||||
|
||||
侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
|
||||
|
||||
侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
|
||||
|
||||
## Claude Code
|
||||
|
||||
Claude Code Adapter 代码暂时保留,但当前网页侧边栏只开放 Codex。后续开放 Claude 入口时,Canvas Agent 会调用本机 `claude -p --output-format stream-json` 并把流式 JSON 事件转发到侧边栏。
|
||||
|
||||
如果希望 Claude Code 也能操作画布,需要给 Claude Code 添加同一个 MCP。建议用 user scope,避免 Canvas Agent 从不同目录启动时找不到配置:
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp
|
||||
```
|
||||
|
||||
本仓库开发时可以改成:
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user --transport stdio infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp
|
||||
```
|
||||
|
||||
Canvas Agent 调用 Claude Code 时会默认带上 `--allowedTools mcp__infinite-canvas__*`,画布写操作仍由网页侧边栏确认。
|
||||
@@ -0,0 +1,302 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@basketikun/canvas-agent",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "^0.139.0",
|
||||
"express": "^5.1.0",
|
||||
"zod": "^3.25.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "https://registry.npmmirror.com/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
|
||||
|
||||
"@openai/codex": ["@openai/codex@0.139.0", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0.tgz", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.139.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.139.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.139.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.139.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.139.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.139.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-wr2fRE+fzW0CjEbfFsLh1ftarVEcw0CMLWS7QyA0nyOz5qacQPVq3cq2+/U7oEbwm1TOqoi0Fm1nxniB5FkpmA=="],
|
||||
|
||||
"@openai/codex-darwin-arm64": ["@openai/codex@0.139.0-darwin-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-darwin-arm64.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-o+0ZKWwgDFMMLO7rwinzO0PQsgK+Vme1pMN2GeAxsX29ZgGZcyPICfpJbeGSUO1mb2a36Skjx6nfdRnxMY0r7w=="],
|
||||
|
||||
"@openai/codex-darwin-x64": ["@openai/codex@0.139.0-darwin-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-darwin-x64.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-9gkBWzu6DB2rqU4DbpxD3DE5bofGpsK46Lp0h0I+bKWc2IIcxvSi8K2utKmBLoJCbKrn4JQu7dFNGRqEfENung=="],
|
||||
|
||||
"@openai/codex-linux-arm64": ["@openai/codex@0.139.0-linux-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-arm64.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-tBQE5lZciRHeWZGuURgjP9S717MvTIpQMc593+DNxY2LQxozkngOkzFSQd1+/UmQKGrCqdFLu5irIwPXpSZyEw=="],
|
||||
|
||||
"@openai/codex-linux-x64": ["@openai/codex@0.139.0-linux-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-linux-x64.tgz", { "os": "linux", "cpu": "x64" }, "sha512-14UgzDS+X4crkvdt6S02A/ZZOrS8ZyWiuTRpguCtnhNamb7unSuDxy86BWgpAl3sqiTaN2CP8VLyp2ohQ8Nbzw=="],
|
||||
|
||||
"@openai/codex-win32-arm64": ["@openai/codex@0.139.0-win32-arm64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-arm64.tgz", { "os": "win32", "cpu": "arm64" }, "sha512-nlwRjsYotH1Rtqu/Q0VwQbIeO2UX1mkHK84Ov9qn/hl29QqqoBtno0tRyqIPbkXFIVQuWiAYXlV3ugLwH5fTrQ=="],
|
||||
|
||||
"@openai/codex-win32-x64": ["@openai/codex@0.139.0-win32-x64", "https://registry.npmmirror.com/@openai/codex/-/codex-0.139.0-win32-x64.tgz", { "os": "win32", "cpu": "x64" }, "sha512-lQrVLNz+90wdvWVNFDvCkHQRiAK9ZllmkTka3c8eqSDqdJk35Gpgppfv9Xtw5M2ZBtTq0sBdWBiCMyzGDBSpmQ=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/express": ["@types/express@5.0.6", "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", "@types/serve-static": "^2" } }, "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@5.1.1", "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/node": ["@types/node@22.19.21", "https://registry.npmmirror.com/@types/node/-/node-22.19.21.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.15.1", "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@2.2.0", "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ajv": ["ajv@8.20.0", "https://registry.npmmirror.com/ajv/-/ajv-8.20.0.tgz", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "https://registry.npmmirror.com/ajv-formats/-/ajv-formats-3.0.1.tgz", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "https://registry.npmmirror.com/body-parser/-/body-parser-2.2.2.tgz", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "https://registry.npmmirror.com/content-disposition/-/content-disposition-1.1.0.tgz", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "https://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.2.2.tgz", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.6", "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.1", "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", { "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=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "https://registry.npmmirror.com/eventsource/-/eventsource-3.0.7.tgz", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.1.0", "https://registry.npmmirror.com/eventsource-parser/-/eventsource-parser-3.1.0.tgz", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="],
|
||||
|
||||
"express": ["express@5.2.1", "https://registry.npmmirror.com/express/-/express-5.2.1.tgz", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@8.5.2", "https://registry.npmmirror.com/express-rate-limit/-/express-rate-limit-8.5.2.tgz", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "https://registry.npmmirror.com/fast-uri/-/fast-uri-3.1.2.tgz", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "https://registry.npmmirror.com/fresh/-/fresh-2.0.0.tgz", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"hono": ["hono@4.12.25", "https://registry.npmmirror.com/hono/-/hono-4.12.25.tgz", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.2", "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ip-address": ["ip-address@10.2.0", "https://registry.npmmirror.com/ip-address/-/ip-address-10.2.0.tgz", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "https://registry.npmmirror.com/is-promise/-/is-promise-4.0.0.tgz", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.2.3", "https://registry.npmmirror.com/jose/-/jose-6.2.3.tgz", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "https://registry.npmmirror.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "https://registry.npmmirror.com/media-typer/-/media-typer-1.1.0.tgz", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "https://registry.npmmirror.com/mime-db/-/mime-db-1.54.0.tgz", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "https://registry.npmmirror.com/negotiator/-/negotiator-1.0.0.tgz", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "https://registry.npmmirror.com/once/-/once-1.4.0.tgz", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.2", "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-8.4.2.tgz", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "https://registry.npmmirror.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.15.2", "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "https://registry.npmmirror.com/raw-body/-/raw-body-3.0.2.tgz", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "https://registry.npmmirror.com/require-from-string/-/require-from-string-2.0.2.tgz", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "https://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tsx": ["tsx@4.22.4", "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg=="],
|
||||
|
||||
"type-is": ["type-is@2.1.0", "https://registry.npmmirror.com/type-is/-/type-is-2.1.0.tgz", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@2.0.2", "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"type-is/content-type": ["content-type@2.0.0", "https://registry.npmmirror.com/content-type/-/content-type-2.0.0.tgz", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@basketikun/canvas-agent",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"canvas-agent": "./dist/index.js"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"prepack": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@openai/codex": "^0.139.0",
|
||||
"express": "^5.1.0",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.8.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { AGENT_PROMPT, VERSION } from "./config.js";
|
||||
import type { AgentAttachment, AgentEmit } from "./types.js";
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type AgentEvent = Json & { type: string; usage?: unknown };
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
type CodexRunOptions = { threadId?: string; cwd?: string };
|
||||
type AgentHistoryMessage = { id: string; role: "user" | "assistant" | "tool" | "error"; title?: string; text: string; detail?: unknown; streamId?: string };
|
||||
|
||||
let codexQueue: Promise<unknown> = Promise.resolve();
|
||||
let codexApp: CodexAppClient | null = null;
|
||||
let codexThreadId = "";
|
||||
const canvasAgentMcp = canvasAgentMcpCommand();
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
export function withAgentPrompt(prompt: string) {
|
||||
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
||||
}
|
||||
|
||||
export async function runCodexTurn(prompt: string, emit: AgentEmit, attachments: AgentAttachment[] = [], options: CodexRunOptions = {}) {
|
||||
if (!prompt.trim()) return;
|
||||
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
||||
await codexQueue;
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
} finally {
|
||||
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
||||
}
|
||||
}
|
||||
|
||||
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
|
||||
codexApp ||= await CodexAppClient.start(emit);
|
||||
const thread = await codexApp.startThread(cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
return thread;
|
||||
}
|
||||
|
||||
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; 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"],
|
||||
cwd: options.cwd,
|
||||
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
||||
});
|
||||
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, cwd?: string) {
|
||||
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
||||
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
export function runClaudeTurn(prompt: string, emit: AgentEmit) {
|
||||
if (!prompt.trim()) return;
|
||||
const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", prompt], ["ignore", "pipe", "pipe"], emit);
|
||||
if (!child) return;
|
||||
pipeJsonLines(child, emit, "claude");
|
||||
}
|
||||
|
||||
async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions) {
|
||||
if (options.threadId) {
|
||||
const result = await app.readThread(options.threadId, false);
|
||||
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
|
||||
const thread = await app.resumeThread(options.threadId, options.cwd);
|
||||
assertThreadWorkspace(thread, options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || options.threadId);
|
||||
return codexThreadId;
|
||||
}
|
||||
if (!codexThreadId) {
|
||||
const thread = await app.startThread(options.cwd);
|
||||
codexThreadId = String(field(thread, "id") || "");
|
||||
}
|
||||
return codexThreadId;
|
||||
}
|
||||
|
||||
class CodexAppClient {
|
||||
private nextId = 1;
|
||||
private buffer = "";
|
||||
private textByItem = new Map<string, string>();
|
||||
private deltaCount = 0;
|
||||
private lastUsage: unknown = null;
|
||||
private pending = new Map<number, PendingRequest>();
|
||||
private activeTurns = new Map<string, PendingRequest>();
|
||||
private completedTurns = new Map<string, Error | null>();
|
||||
|
||||
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
|
||||
|
||||
static async start(emit: AgentEmit) {
|
||||
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
||||
const client = new CodexAppClient(child, emit);
|
||||
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||
child.on("exit", (code) => {
|
||||
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
||||
codexApp = null;
|
||||
codexThreadId = "";
|
||||
emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
|
||||
});
|
||||
await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
||||
client.notify("initialized");
|
||||
return client;
|
||||
}
|
||||
|
||||
async startThread(cwd?: string) {
|
||||
const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
||||
const thread = field(result, "thread") as Json | undefined;
|
||||
const id = String(field(thread, "id") || "");
|
||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread || {};
|
||||
}
|
||||
|
||||
async resumeThread(threadId: string, cwd?: string) {
|
||||
const result = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) });
|
||||
const thread = field(result, "thread") as Json | undefined;
|
||||
const id = String(field(thread, "id") || "");
|
||||
if (!id) throw new Error("Codex app-server 没有返回 thread id");
|
||||
return thread || {};
|
||||
}
|
||||
|
||||
listThreads(params: Json) {
|
||||
return this.request("thread/list", params);
|
||||
}
|
||||
|
||||
readThread(threadId: string, includeTurns = true) {
|
||||
return this.request("thread/read", { threadId, includeTurns });
|
||||
}
|
||||
|
||||
archiveThread(threadId: string) {
|
||||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
async startTurn(threadId: string, prompt: string, images: string[]) {
|
||||
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
||||
const turnId = String(field(field(result, "turn"), "id") || "");
|
||||
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
|
||||
const completed = this.completedTurns.get(turnId);
|
||||
if (this.completedTurns.has(turnId)) {
|
||||
this.completedTurns.delete(turnId);
|
||||
if (completed) throw completed;
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
||||
}
|
||||
|
||||
private request(method: string, params: unknown) {
|
||||
const id = this.nextId++;
|
||||
this.write({ id, method, params });
|
||||
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
||||
}
|
||||
|
||||
private notify(method: string, params?: unknown) {
|
||||
this.write(params === undefined ? { method } : { method, params });
|
||||
}
|
||||
|
||||
private write(value: unknown) {
|
||||
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
||||
}
|
||||
|
||||
private read(chunk: string) {
|
||||
this.buffer += chunk;
|
||||
const lines = this.buffer.split(/\r?\n/);
|
||||
this.buffer = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
this.handle(JSON.parse(line) as Json);
|
||||
} catch {
|
||||
this.emit("agent_log", { text: line });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handle(message: Json) {
|
||||
const id = Number(message.id);
|
||||
if (message.error && this.pending.has(id)) return this.reject(id, String(field(message.error, "message") || "Codex request failed"));
|
||||
if (this.pending.has(id)) return this.resolve(id, message.result);
|
||||
if (typeof message.method === "string" && "id" in message) return this.answerServerRequest(message);
|
||||
if (typeof message.method === "string") this.handleNotification(message.method, (message.params || {}) as Json);
|
||||
}
|
||||
|
||||
private handleNotification(method: string, params: Json) {
|
||||
if (method === "item/agentMessage/delta") return this.emitDelta(params);
|
||||
if (method === "thread/tokenUsage/updated") this.lastUsage = normalizeUsage(params);
|
||||
const event = normalizeCodexNotification(method, params);
|
||||
if (!event) return;
|
||||
if (event.type === "turn.completed") event.usage = this.lastUsage;
|
||||
this.emit("agent_event", { agent: "codex", ...event });
|
||||
if (event.type === "turn.completed") {
|
||||
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
||||
const pending = this.activeTurns.get(turnId);
|
||||
const error = field(field(params, "turn"), "error");
|
||||
if (pending) {
|
||||
this.activeTurns.delete(turnId);
|
||||
error ? pending.reject(new Error(String(field(error, "message") || "Codex turn failed"))) : pending.resolve(event);
|
||||
} else if (turnId) {
|
||||
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
||||
}
|
||||
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount });
|
||||
this.deltaCount = 0;
|
||||
this.emit("agent_done", { agent: "codex", usage: event.usage });
|
||||
}
|
||||
}
|
||||
|
||||
private emitDelta(params: Json) {
|
||||
const id = String(field(params, "itemId") || "");
|
||||
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
||||
this.deltaCount += 1;
|
||||
this.textByItem.set(id, text);
|
||||
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } });
|
||||
}
|
||||
|
||||
private answerServerRequest(message: Json) {
|
||||
const method = String(message.method);
|
||||
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
||||
this.write({ id: message.id, result });
|
||||
this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result });
|
||||
}
|
||||
|
||||
private resolve(id: number, result: unknown) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.resolve(result));
|
||||
}
|
||||
|
||||
private reject(id: number, message: string) {
|
||||
const pending = this.pending.get(id);
|
||||
if (pending) (this.pending.delete(id), pending.reject(new Error(message)));
|
||||
}
|
||||
|
||||
failAll(message: string) {
|
||||
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
||||
this.pending.clear();
|
||||
this.activeTurns.clear();
|
||||
}
|
||||
}
|
||||
|
||||
function canvasAgentMcpCommand() {
|
||||
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
||||
const entry = path.resolve(current || fileURLToPath(new URL("./index.js", import.meta.url)));
|
||||
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
||||
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
||||
}
|
||||
|
||||
function codexConfig() {
|
||||
return { mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
||||
}
|
||||
|
||||
function codexInput(prompt: string, images: string[]) {
|
||||
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
|
||||
}
|
||||
|
||||
function normalizeCodexNotification(method: string, params: Json): AgentEvent | null {
|
||||
if (method === "thread/started") return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
||||
if (method === "turn/started") return { type: "turn.started" };
|
||||
if (method === "turn/completed") return { type: "turn.completed", usage: null };
|
||||
if (method === "item/started") return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "item/completed") return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
||||
if (method === "error") return { type: "error", message: field(params, "message") };
|
||||
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";
|
||||
if (value.type === "mcpToolCall") value.type = "mcp_tool_call";
|
||||
if (value.type === "agent_message" && typeof value.id === "string") value.text = String(value.text || "");
|
||||
if ("arguments" in value) value.arguments = parseMaybeJson(value.arguments);
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeUsage(params: Json) {
|
||||
const total = field(field(params, "tokenUsage"), "total") as Json | undefined;
|
||||
return {
|
||||
input_tokens: field(total, "inputTokens"),
|
||||
cached_input_tokens: field(total, "cachedInputTokens"),
|
||||
output_tokens: field(total, "outputTokens"),
|
||||
reasoning_output_tokens: field(total, "reasoningOutputTokens"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseMaybeJson(value: unknown) {
|
||||
if (typeof value !== "string") return value;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function field(value: unknown, key: string) {
|
||||
return value && typeof value === "object" ? (value as Json)[key] : undefined;
|
||||
}
|
||||
|
||||
export function summarizeCodexThread(thread: unknown) {
|
||||
return {
|
||||
id: String(field(thread, "id") || ""),
|
||||
sessionId: String(field(thread, "sessionId") || ""),
|
||||
preview: displayUserText(String(field(thread, "preview") || "")),
|
||||
name: stringOrNull(field(thread, "name")),
|
||||
cwd: String(field(thread, "cwd") || ""),
|
||||
status: String(field(thread, "status") || ""),
|
||||
source: field(thread, "source"),
|
||||
threadSource: field(thread, "threadSource"),
|
||||
createdAt: Number(field(thread, "createdAt") || 0),
|
||||
updatedAt: Number(field(thread, "updatedAt") || 0),
|
||||
};
|
||||
}
|
||||
|
||||
function threadMessages(thread: unknown): AgentHistoryMessage[] {
|
||||
const turns = arrayValue(field(thread, "turns"));
|
||||
const messages: AgentHistoryMessage[] = [];
|
||||
turns.forEach((turn, turnIndex) => {
|
||||
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
||||
if (type === "userMessage") {
|
||||
const text = displayUserText(userInputText(field(item, "content")));
|
||||
if (text) messages.push({ id, role: "user", text });
|
||||
}
|
||||
if (type === "agentMessage") {
|
||||
const text = String(field(item, "text") || "").trim();
|
||||
if (text) messages.push({ id, role: "assistant", title: "Codex", text, streamId: id });
|
||||
}
|
||||
if (type === "mcpToolCall") {
|
||||
const tool = String(field(item, "tool") || "工具调用");
|
||||
const error = field(field(item, "error"), "message");
|
||||
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
|
||||
}
|
||||
if (type === "commandExecution") {
|
||||
const command = String(field(item, "command") || "").trim();
|
||||
if (command) messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
|
||||
}
|
||||
if (type === "fileChange") messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
|
||||
});
|
||||
});
|
||||
return messages.filter((item) => item.text).slice(-120);
|
||||
}
|
||||
|
||||
function userInputText(content: unknown) {
|
||||
return arrayValue(content)
|
||||
.map((item) => {
|
||||
const type = String(field(item, "type") || "");
|
||||
if (type === "text") return String(field(item, "text") || "");
|
||||
if (type === "image" || type === "localImage") return "图片附件";
|
||||
if (type === "mention") return `@${String(field(item, "name") || "文件")}`;
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function displayUserText(text: string) {
|
||||
const value = text.trim();
|
||||
const marker = "用户请求:";
|
||||
const index = value.lastIndexOf(marker);
|
||||
return (index >= 0 ? value.slice(index + marker.length) : value).trim();
|
||||
}
|
||||
|
||||
function arrayValue(value: unknown) {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value : null;
|
||||
}
|
||||
|
||||
function toolName(name: string) {
|
||||
if (name === "canvas_apply_ops") return "画布操作";
|
||||
if (name === "canvas_get_state") return "读取画布";
|
||||
if (name === "canvas_get_selection") return "读取选区";
|
||||
if (name === "canvas_export_snapshot") return "导出快照";
|
||||
if (name === "canvas_create_text_node") return "创建文本";
|
||||
if (name === "canvas_create_image_prompt_flow") return "创建生图流程";
|
||||
if (name === "canvas_create_generation_flow") return "创建生成流程";
|
||||
if (name === "canvas_generate_text") return "生成文本";
|
||||
if (name === "canvas_generate_image") return "生成图片";
|
||||
if (name === "canvas_generate_video") return "生成视频";
|
||||
if (name === "canvas_generate_audio") return "生成音频";
|
||||
if (name === "canvas_run_generation") return "触发生成";
|
||||
return name;
|
||||
}
|
||||
|
||||
async function writeAttachmentFiles(attachments: AgentAttachment[]) {
|
||||
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
||||
}
|
||||
|
||||
async function writeAttachmentFile(item: AgentAttachment) {
|
||||
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
||||
if (!data) throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
||||
const file = path.join(os.tmpdir(), `infinite-canvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
||||
await fs.writeFile(file, Buffer.from(data, "base64"));
|
||||
return file;
|
||||
}
|
||||
|
||||
function imageExt(type = "") {
|
||||
if (type.includes("png")) return "png";
|
||||
if (type.includes("webp")) return "webp";
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
function codexBin() {
|
||||
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
||||
}
|
||||
|
||||
function pipeJsonLines(child: ReturnType<typeof spawn>, emit: AgentEmit, agent: string) {
|
||||
let out = "";
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
out += chunk.toString();
|
||||
const lines = out.split(/\r?\n/);
|
||||
out = lines.pop() || "";
|
||||
lines.filter(Boolean).forEach((line) => {
|
||||
try {
|
||||
emit("agent_event", { agent, ...JSON.parse(line) });
|
||||
} catch {
|
||||
emit("agent_event", { agent, type: "raw", text: line });
|
||||
}
|
||||
});
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
|
||||
child.on("error", (error) => emit("agent_error", { message: error.message }));
|
||||
child.on("close", (code) => emit("agent_done", { agent, code }));
|
||||
}
|
||||
|
||||
function spawnAgent(name: string, args: string[], stdio: StdioOptions, emit: AgentEmit) {
|
||||
try {
|
||||
return spawn(name, args, { stdio, shell: process.platform === "win32", windowsHide: true });
|
||||
} catch (error) {
|
||||
emit("agent_error", { message: errorMessage(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { ServerResponse } from "node:http";
|
||||
|
||||
import { type ToolName } from "./schemas.js";
|
||||
import { compactCanvasState, compactNode, isToolName, nextCanvasX, parseToolInput } from "./tools.js";
|
||||
import type { CanvasNode, CanvasNodeType, CanvasSnapshot } from "./types.js";
|
||||
|
||||
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
||||
|
||||
export class CanvasSession {
|
||||
private clients = new Map<string, ServerResponse>();
|
||||
private pending = new Map<string, PendingRequest>();
|
||||
private canvasState: CanvasSnapshot | null = null;
|
||||
|
||||
health() {
|
||||
return { ok: true, hasCanvas: Boolean(this.canvasState), clients: this.clients.size };
|
||||
}
|
||||
|
||||
openEvents(url: URL, res: ServerResponse) {
|
||||
const clientId = url.searchParams.get("clientId") || crypto.randomUUID();
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" });
|
||||
this.clients.set(clientId, res);
|
||||
sendEvent(res, "hello", { ok: true, clientId });
|
||||
const timer = setInterval(() => sendEvent(res, "ping", { time: Date.now() }), 15000);
|
||||
res.on("close", () => {
|
||||
clearInterval(timer);
|
||||
this.clients.delete(clientId);
|
||||
if (this.canvasState?.clientId === clientId) this.canvasState = null;
|
||||
});
|
||||
}
|
||||
|
||||
updateState(body: unknown, clientId?: string) {
|
||||
this.canvasState = { ...((body && typeof body === "object" && !Array.isArray(body) ? body : {}) as Record<string, unknown>), clientId } as CanvasSnapshot;
|
||||
}
|
||||
|
||||
resolveResult(body: { requestId?: string; error?: string; result?: unknown }) {
|
||||
const item = body.requestId ? this.pending.get(body.requestId) : null;
|
||||
if (!item || !body.requestId) return;
|
||||
this.pending.delete(body.requestId);
|
||||
body.error ? item.reject(new Error(body.error)) : item.resolve(body.result);
|
||||
}
|
||||
|
||||
emitAll(type: string, payload: unknown) {
|
||||
this.clients.forEach((client) => sendEvent(client, type, payload));
|
||||
}
|
||||
|
||||
async callTool(name: unknown, rawInput: unknown) {
|
||||
if (!isToolName(name)) throw new Error(`未知工具:${String(name)}`);
|
||||
let tool: ToolName = name;
|
||||
let input = parseToolInput(tool, rawInput) as Record<string, unknown>;
|
||||
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);
|
||||
if (tool === "canvas_get_selection") {
|
||||
const ids = new Set(this.canvasState?.selectedNodeIds || []);
|
||||
return { nodes: (this.canvasState?.nodes || []).filter((node) => ids.has(node.id)).map(compactNode) };
|
||||
}
|
||||
if (tool === "canvas_create_node") {
|
||||
const data = input as { nodeType: CanvasNodeType; title?: string; x?: number; y?: number; width?: number; height?: number; metadata?: Record<string, unknown> };
|
||||
input = { ops: [{ type: "add_node", nodeType: data.nodeType, title: data.title, position: { x: data.x ?? nextCanvasX(this.canvasState), y: data.y ?? 0 }, width: data.width, height: data.height, metadata: data.metadata }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_text_node") {
|
||||
const text = input as { text?: string; x?: number; y?: number; title?: string; width?: number; height?: number };
|
||||
input = { ops: [textNodeOp(text, text.x ?? nextCanvasX(this.canvasState), text.y ?? 0)] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_text_nodes") {
|
||||
const data = input as { items: Array<{ text: string; title?: string; x?: number; y?: number; width?: number; height?: number }>; x?: number; y?: number; gap?: number; direction?: "row" | "column" };
|
||||
const x = Number(data.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(data.y ?? 0);
|
||||
const gap = Number(data.gap ?? 40);
|
||||
input = {
|
||||
ops: data.items.map((item, index) => textNodeOp(item, item.x ?? (data.direction === "row" ? x + index * (340 + gap) : x), item.y ?? (data.direction === "row" ? y : y + index * (240 + gap)))),
|
||||
};
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_image_prompt_flow") {
|
||||
input = { ops: generationFlowOps({ ...(input as Record<string, unknown>), mode: "image" }, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_config_node") {
|
||||
const data = input as Record<string, unknown>;
|
||||
const x = Number(data.x ?? nextCanvasX(this.canvasState));
|
||||
const y = Number(data.y ?? 0);
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const mode = generationMode(data.mode);
|
||||
const prompt = String(data.prompt || "");
|
||||
input = { ops: [configNodeOp(configId, data, x, y), ...(data.autoRun ? [runGenerationOp(configId, mode, prompt)] : [])] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_create_generation_flow") {
|
||||
input = { ops: generationFlowOps(input as Record<string, unknown>, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_generate_text" || tool === "canvas_generate_image" || tool === "canvas_generate_video" || tool === "canvas_generate_audio") {
|
||||
input = { ops: generationFlowOps({ ...(input as Record<string, unknown>), mode: tool.replace("canvas_generate_", ""), autoRun: true }, this.canvasState) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_update_node") {
|
||||
const data = input as { id: string; patch?: Record<string, unknown>; metadata?: Record<string, unknown> };
|
||||
input = { ops: [{ type: "update_node", id: data.id, patch: data.patch, metadata: data.metadata }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_update_node_text") {
|
||||
const data = input as { id: string; text: string; title?: string };
|
||||
input = { ops: [{ type: "update_node", id: data.id, patch: { ...(data.title ? { title: data.title } : {}) }, metadata: { content: data.text, status: "success" } }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_move_nodes") {
|
||||
const data = input as { items: Array<{ id: string; x?: number; y?: number; dx?: number; dy?: number }> };
|
||||
input = {
|
||||
ops: data.items.map((item) => {
|
||||
const current = findNode(this.canvasState, item.id);
|
||||
return { type: "update_node", id: item.id, patch: { position: { x: item.x ?? ((current?.position.x || 0) + (item.dx || 0)), y: item.y ?? ((current?.position.y || 0) + (item.dy || 0)) } } };
|
||||
}),
|
||||
};
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_resize_node") {
|
||||
const data = input as { id: string; width: number; height: number; freeResize?: boolean };
|
||||
input = { ops: [{ type: "update_node", id: data.id, patch: { width: data.width, height: data.height }, metadata: data.freeResize === undefined ? undefined : { freeResize: data.freeResize } }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_delete_nodes") {
|
||||
input = { ops: [{ type: "delete_node", ids: (input as { ids: string[] }).ids }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_connect_nodes") {
|
||||
const data = input as { connections: Array<{ fromNodeId: string; toNodeId: string }> };
|
||||
input = { ops: data.connections.map((connection) => ({ type: "connect_nodes", ...connection })) };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_select_nodes") {
|
||||
input = { ops: [{ type: "select_nodes", ids: (input as { ids: string[] }).ids }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_set_viewport") {
|
||||
input = { ops: [{ type: "set_viewport", viewport: (input as { viewport: unknown }).viewport }] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool === "canvas_run_generation") {
|
||||
const data = input as { nodeId: string; mode?: string; prompt?: string };
|
||||
input = { ops: [runGenerationOp(data.nodeId, generationMode(data.mode), data.prompt)] };
|
||||
tool = "canvas_apply_ops";
|
||||
}
|
||||
if (tool !== "canvas_apply_ops") throw new Error(`未知工具:${tool}`);
|
||||
if (!this.clients.size) throw new Error("当前没有已连接画布");
|
||||
return await this.requestCanvasTool(tool, input);
|
||||
}
|
||||
|
||||
private async requestCanvasTool(name: ToolName, input: Record<string, unknown>) {
|
||||
const requestId = crypto.randomUUID();
|
||||
const client = this.clients.get(this.canvasState?.clientId || "") || this.clients.values().next().value;
|
||||
if (!client) throw new Error("当前没有已连接画布");
|
||||
sendEvent(client, "tool_call", { requestId, name, input });
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pending.delete(requestId);
|
||||
reject(new Error("画布操作超时"));
|
||||
}, 30000);
|
||||
this.pending.set(requestId, { resolve: (value) => (clearTimeout(timer), resolve(value)), reject: (error) => (clearTimeout(timer), reject(error)) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendEvent(res: ServerResponse, type: string, payload: unknown) {
|
||||
res.write(`event: ${type}\ndata: ${JSON.stringify(payload)}\n\n`);
|
||||
}
|
||||
|
||||
function textNodeOp(input: { id?: string; text?: string; title?: string; width?: number; height?: number }, x: number, y: number) {
|
||||
return { type: "add_node", id: input.id, nodeType: "text", title: input.title, position: { x, y }, width: input.width, height: input.height, metadata: { content: input.text || "", status: "success", fontSize: 14 } };
|
||||
}
|
||||
|
||||
function configNodeOp(id: string, input: Record<string, unknown>, x: number, y: number) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
return {
|
||||
type: "add_node",
|
||||
id,
|
||||
nodeType: "config",
|
||||
title: String(input.title || generationTitle(mode)),
|
||||
position: { x, y },
|
||||
width: typeof input.width === "number" ? input.width : undefined,
|
||||
height: typeof input.height === "number" ? input.height : undefined,
|
||||
metadata: cleanRecord({
|
||||
generationMode: mode,
|
||||
composerContent: prompt,
|
||||
prompt,
|
||||
status: "idle",
|
||||
model: input.model,
|
||||
size: input.size,
|
||||
quality: input.quality,
|
||||
count: input.count,
|
||||
seconds: input.seconds,
|
||||
vquality: input.vquality,
|
||||
generateAudio: input.generateAudio,
|
||||
watermark: input.watermark,
|
||||
audioVoice: input.audioVoice,
|
||||
audioFormat: input.audioFormat,
|
||||
audioSpeed: input.audioSpeed,
|
||||
audioInstructions: input.audioInstructions,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function generationFlowOps(input: Record<string, unknown>, state: CanvasSnapshot | null) {
|
||||
const mode = generationMode(input.mode);
|
||||
const prompt = String(input.prompt || "");
|
||||
const x = Number(input.x ?? nextCanvasX(state));
|
||||
const y = Number(input.y ?? 0);
|
||||
const textId = `text-${crypto.randomUUID()}`;
|
||||
const configId = `config-${crypto.randomUUID()}`;
|
||||
const referenceNodeIds = Array.isArray(input.referenceNodeIds) ? input.referenceNodeIds.filter((id): id is string => typeof id === "string") : [];
|
||||
const tokens = [`@[node:${textId}]`, ...referenceNodeIds.map((id) => `@[node:${id}]`)];
|
||||
const configInput = { ...input, prompt: tokens.join("\n") };
|
||||
return [
|
||||
textNodeOp({ id: textId, text: prompt, title: String(input.title || "提示词") }, x, y),
|
||||
configNodeOp(configId, configInput, x + 420, y),
|
||||
{ type: "connect_nodes", fromNodeId: textId, toNodeId: configId },
|
||||
...referenceNodeIds.map((fromNodeId) => ({ type: "connect_nodes", fromNodeId, toNodeId: configId })),
|
||||
{ type: "select_nodes", ids: [configId] },
|
||||
...(input.autoRun ? [runGenerationOp(configId, mode, tokens.join("\n"))] : []),
|
||||
];
|
||||
}
|
||||
|
||||
function runGenerationOp(nodeId: string, mode: "text" | "image" | "video" | "audio", prompt?: string) {
|
||||
return { type: "run_generation", nodeId, mode, prompt };
|
||||
}
|
||||
|
||||
function generationMode(value: unknown): "text" | "image" | "video" | "audio" {
|
||||
return value === "text" || value === "video" || value === "audio" ? value : "image";
|
||||
}
|
||||
|
||||
function generationTitle(mode: "text" | "image" | "video" | "audio") {
|
||||
if (mode === "text") return "文本生成";
|
||||
if (mode === "video") return "视频生成";
|
||||
if (mode === "audio") return "音频生成";
|
||||
return "图片生成";
|
||||
}
|
||||
|
||||
function findNode(state: CanvasSnapshot | null, id: string): CanvasNode | undefined {
|
||||
return (state?.nodes || []).find((node) => node.id === id);
|
||||
}
|
||||
|
||||
function cleanRecord(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== ""));
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const DEFAULT_PORT = 17371;
|
||||
export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
|
||||
export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
|
||||
export const VERSION = readPackageVersion();
|
||||
export const AGENT_PROMPT = "你正在帮助用户操作 Infinite Canvas 网页画布。需要改动画布时优先使用已配置的 infinite-canvas MCP 工具:先 canvas_get_state 读取当前画布,再根据任务使用 canvas_create_text_node、canvas_generate_text、canvas_generate_image、canvas_generate_video、canvas_generate_audio、canvas_create_generation_flow、canvas_create_config_node、canvas_run_generation、canvas_update_node、canvas_connect_nodes 等通用工具;复杂批量改动再用 canvas_apply_ops,删除连线可用 delete_connections。需要生成内容时直接调用对应生成工具,不要绑定特定业务场景。不要模拟鼠标点击,不要要求用户手动复制 JSON。";
|
||||
|
||||
export type CanvasWorkspaceConfig = { workspacePath: string; activeThreadId?: string; pinnedThreadIds?: string[] };
|
||||
export type CanvasAgentConfig = { url: string; token: string; origins?: string[]; canvases?: Record<string, CanvasWorkspaceConfig> };
|
||||
|
||||
export function loadConfig(create = false): CanvasAgentConfig {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")) as CanvasAgentConfig;
|
||||
} catch {
|
||||
const config = { url: `http://127.0.0.1:${Number(process.env.PORT) || DEFAULT_PORT}`, token: crypto.randomBytes(18).toString("hex") };
|
||||
if (create) saveConfig(config);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveConfig(config: CanvasAgentConfig) {
|
||||
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
||||
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];
|
||||
if (current?.workspacePath) {
|
||||
fs.mkdirSync(resolveWorkspacePath(current.workspacePath), { recursive: true });
|
||||
return { canvasId: id, ...current, workspacePath: resolveWorkspacePath(current.workspacePath) };
|
||||
}
|
||||
const workspacePath = path.join(CONFIG_DIR, "codex-workspaces", id);
|
||||
config.canvases[id] = { workspacePath };
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
saveConfig(config);
|
||||
return { canvasId: id, workspacePath };
|
||||
}
|
||||
|
||||
export function updateCanvasWorkspace(config: CanvasAgentConfig, canvasId: string, patch: Partial<CanvasWorkspaceConfig>) {
|
||||
const current = ensureCanvasWorkspace(config, canvasId);
|
||||
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 };
|
||||
fs.mkdirSync(workspacePath, { recursive: true });
|
||||
saveConfig(config);
|
||||
return { canvasId: current.canvasId, ...config.canvases[current.canvasId] };
|
||||
}
|
||||
|
||||
function resolveWorkspacePath(value: string) {
|
||||
if (value === "~") return os.homedir();
|
||||
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
||||
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 };
|
||||
return pkg.version || "0.0.0";
|
||||
} catch {
|
||||
return "0.0.0";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import express, { type NextFunction, type Request, type Response } from "express";
|
||||
|
||||
import { DEFAULT_PORT, ensureCanvasWorkspace, loadConfig, saveConfig, updateCanvasWorkspace, type CanvasAgentConfig } from "./config.js";
|
||||
import { CanvasSession } from "./canvas-session.js";
|
||||
import { archiveCodexThread, listCodexThreads, readCodexThread, resumeCodexThread, runClaudeTurn, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace, withAgentPrompt } from "./agents.js";
|
||||
import type { AgentAttachment } from "./types.js";
|
||||
|
||||
export function startHttpServer() {
|
||||
const config = loadConfig(true);
|
||||
const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
|
||||
config.url = `http://127.0.0.1:${port}`;
|
||||
saveConfig(config);
|
||||
|
||||
const session = new CanvasSession();
|
||||
const emit = (type: string, payload: unknown) => session.emitAll(type, payload);
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
app.use(express.json({ limit: "30mb" }));
|
||||
app.use((req, res, next) => {
|
||||
const url = requestUrl(req, config);
|
||||
if (!setCors(req, res, url, config)) return void res.status(403).json({ ok: false, error: "origin not allowed" });
|
||||
if (req.method === "OPTIONS") return void res.json({});
|
||||
next();
|
||||
});
|
||||
app.get("/health", (_req, res) => res.json(session.health()));
|
||||
app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
|
||||
app.use((req, res, next) => {
|
||||
if (validToken(req, requestUrl(req, config), config.token)) return next();
|
||||
res.status(401).json({ ok: false, error: "invalid token" });
|
||||
});
|
||||
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
|
||||
app.post("/canvas/state", (req, res) => {
|
||||
session.updateState(req.body, String(req.query.clientId || "") || undefined);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.post("/canvas/result", (req, res) => {
|
||||
session.resolveResult(req.body);
|
||||
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 || ""));
|
||||
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, 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 || ""));
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
const activeThreadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId }, thread: summarizeCodexThread(thread), messages: [] });
|
||||
}));
|
||||
app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.query.canvasId || ""));
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
res.json({ ok: true, workspace, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/resume", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
res.json({ ok: true, workspace: { ...workspace, activeThreadId: threadId }, ...result });
|
||||
}));
|
||||
app.post("/agent/codex/threads/:threadId/delete", route(async (req, res) => {
|
||||
const workspace = ensureCanvasWorkspace(config, String(req.body?.canvasId || ""));
|
||||
const threadId = routeParam(req.params.threadId);
|
||||
await archiveCodexThread(emit, threadId, workspace.workspacePath);
|
||||
if (workspace.activeThreadId === threadId) updateCanvasWorkspace(config, workspace.canvasId, { 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 || ""));
|
||||
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
|
||||
if (!threadId) {
|
||||
const thread = await startCodexThread(emit, workspace.workspacePath);
|
||||
threadId = String((thread as Record<string, unknown>).id || "");
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
} else if (threadId !== workspace.activeThreadId) {
|
||||
await verifyCodexThreadWorkspace(emit, threadId, workspace.workspacePath);
|
||||
updateCanvasWorkspace(config, workspace.canvasId, { activeThreadId: threadId });
|
||||
}
|
||||
void runCodexTurn(withAgentPrompt(String(req.body?.prompt || "")), emit, attachments, { threadId, cwd: workspace.workspacePath });
|
||||
res.json({ ok: true, threadId });
|
||||
}));
|
||||
app.post("/agent/claude/turn", (req, res) => {
|
||||
runClaudeTurn(withAgentPrompt(String(req.body?.prompt || "")), emit);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
|
||||
app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => res.status(500).json({ ok: false, error: error.message }));
|
||||
|
||||
app.listen(port, "127.0.0.1", () => {
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
function route(handler: (req: Request, res: Response) => Promise<unknown>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => void handler(req, res).catch(next);
|
||||
}
|
||||
|
||||
function routeParam(value: string | string[]) {
|
||||
return Array.isArray(value) ? value[0] || "" : value;
|
||||
}
|
||||
|
||||
function requestUrl(req: Request, config: CanvasAgentConfig) {
|
||||
return new URL(req.originalUrl || req.url || "/", config.url);
|
||||
}
|
||||
|
||||
function setCors(req: Request, res: Response, url: URL, config: CanvasAgentConfig) {
|
||||
const origin = req.headers.origin;
|
||||
res.setHeader("Access-Control-Allow-Origin", origin || "*");
|
||||
res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
||||
if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config") return true;
|
||||
config.origins ||= [];
|
||||
if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
|
||||
config.origins.push(origin);
|
||||
saveConfig(config);
|
||||
}
|
||||
res.setHeader("Vary", "Origin");
|
||||
return config.origins.includes(origin);
|
||||
}
|
||||
|
||||
function validToken(req: Request, url: URL, token: string) {
|
||||
const header = req.headers["x-canvas-agent-token"];
|
||||
return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
import { startHttpServer } from "./http-server.js";
|
||||
import { startMcpServer } from "./mcp-server.js";
|
||||
|
||||
if (process.argv[2] === "mcp") await startMcpServer();
|
||||
else startHttpServer();
|
||||
@@ -0,0 +1,29 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
|
||||
import { AGENT_PROMPT, loadConfig, type CanvasAgentConfig, VERSION } from "./config.js";
|
||||
import { toolDescriptions, toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
|
||||
type CanvasAgentToolResponse = { ok?: boolean; result?: unknown; error?: string };
|
||||
|
||||
export async function startMcpServer() {
|
||||
const config = loadConfig(true);
|
||||
const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
|
||||
toolNames.forEach((name) => registerCanvasTool(server, config, name));
|
||||
await server.connect(new StdioServerTransport());
|
||||
}
|
||||
|
||||
function registerCanvasTool(server: McpServer, config: CanvasAgentConfig, name: ToolName) {
|
||||
const schema = toolInputSchemas[name];
|
||||
server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input: unknown) => {
|
||||
const result = await postCanvasAgentTool(config, name, schema.parse(input));
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }] };
|
||||
});
|
||||
}
|
||||
|
||||
async function postCanvasAgentTool(config: CanvasAgentConfig, name: ToolName, input: unknown) {
|
||||
const res = await fetch(`${config.url}/api/tools`, { method: "POST", headers: { "content-type": "application/json", "x-canvas-agent-token": config.token }, body: JSON.stringify({ name, input }) });
|
||||
const body = (await res.json()) as CanvasAgentToolResponse;
|
||||
if (!body.ok) throw new Error(body.error || "tool call failed");
|
||||
return body.result;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const recordSchema = z.record(z.unknown());
|
||||
const positionSchema = z.object({ x: z.number(), y: z.number() });
|
||||
const viewportSchema = z.object({ x: z.number(), y: z.number(), k: z.number() });
|
||||
const nodeTypeSchema = z.enum(["image", "text", "config", "video", "audio"]);
|
||||
const generationModeSchema = z.enum(["text", "image", "video", "audio"]);
|
||||
|
||||
export const toolNames = [
|
||||
"canvas_get_state",
|
||||
"canvas_get_selection",
|
||||
"canvas_export_snapshot",
|
||||
"canvas_apply_ops",
|
||||
"canvas_create_node",
|
||||
"canvas_create_text_node",
|
||||
"canvas_create_text_nodes",
|
||||
"canvas_create_config_node",
|
||||
"canvas_create_image_prompt_flow",
|
||||
"canvas_create_generation_flow",
|
||||
"canvas_generate_text",
|
||||
"canvas_generate_image",
|
||||
"canvas_generate_video",
|
||||
"canvas_generate_audio",
|
||||
"canvas_update_node",
|
||||
"canvas_update_node_text",
|
||||
"canvas_move_nodes",
|
||||
"canvas_resize_node",
|
||||
"canvas_delete_nodes",
|
||||
"canvas_connect_nodes",
|
||||
"canvas_select_nodes",
|
||||
"canvas_set_viewport",
|
||||
"canvas_run_generation",
|
||||
] as const;
|
||||
export type ToolName = (typeof toolNames)[number];
|
||||
|
||||
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(),
|
||||
z.object({ type: z.literal("run_generation"), nodeId: z.string(), mode: generationModeSchema.optional(), prompt: z.string().optional() }).passthrough(),
|
||||
]);
|
||||
|
||||
const textNodeSchema = z.object({
|
||||
text: z.string(),
|
||||
title: z.string().optional(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
});
|
||||
|
||||
const generationOptionsSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
size: z.string().optional(),
|
||||
quality: z.string().optional(),
|
||||
count: z.number().optional(),
|
||||
seconds: z.string().optional(),
|
||||
vquality: z.string().optional(),
|
||||
generateAudio: z.string().optional(),
|
||||
watermark: z.string().optional(),
|
||||
audioVoice: z.string().optional(),
|
||||
audioFormat: z.string().optional(),
|
||||
audioSpeed: z.string().optional(),
|
||||
audioInstructions: z.string().optional(),
|
||||
});
|
||||
|
||||
const generationFlowSchema = z.object({
|
||||
prompt: z.string(),
|
||||
title: z.string().optional(),
|
||||
x: z.number().optional(),
|
||||
y: z.number().optional(),
|
||||
referenceNodeIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const toolInputSchemas = {
|
||||
canvas_get_state: z.object({}).passthrough(),
|
||||
canvas_get_selection: z.object({}).passthrough(),
|
||||
canvas_export_snapshot: z.object({}).passthrough(),
|
||||
canvas_apply_ops: z.object({ ops: z.array(canvasOpSchema) }),
|
||||
canvas_create_node: z.object({ nodeType: nodeTypeSchema, title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), metadata: recordSchema.optional() }),
|
||||
canvas_create_text_node: z.object({ text: z.string().optional(), x: z.number().optional(), y: z.number().optional(), title: z.string().optional(), width: z.number().optional(), height: z.number().optional() }),
|
||||
canvas_create_text_nodes: z.object({ items: z.array(textNodeSchema).min(1), x: z.number().optional(), y: z.number().optional(), gap: z.number().optional(), direction: z.enum(["row", "column"]).optional() }),
|
||||
canvas_create_config_node: z.object({ prompt: z.string().optional(), mode: generationModeSchema.optional(), title: z.string().optional(), x: z.number().optional(), y: z.number().optional(), width: z.number().optional(), height: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
canvas_create_image_prompt_flow: z.object({ prompt: z.string(), x: z.number().optional(), y: z.number().optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
canvas_create_generation_flow: generationFlowSchema.extend({ mode: generationModeSchema.optional(), autoRun: z.boolean().optional() }).merge(generationOptionsSchema),
|
||||
canvas_generate_text: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_generate_image: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_generate_video: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_generate_audio: generationFlowSchema.merge(generationOptionsSchema),
|
||||
canvas_update_node: z.object({ id: z.string(), patch: recordSchema.optional(), metadata: recordSchema.optional() }),
|
||||
canvas_update_node_text: z.object({ id: z.string(), text: z.string(), title: z.string().optional() }),
|
||||
canvas_move_nodes: z.object({ items: z.array(z.object({ id: z.string(), x: z.number().optional(), y: z.number().optional(), dx: z.number().optional(), dy: z.number().optional() })).min(1) }),
|
||||
canvas_resize_node: z.object({ id: z.string(), width: z.number(), height: z.number(), freeResize: z.boolean().optional() }),
|
||||
canvas_delete_nodes: z.object({ ids: z.array(z.string()).min(1) }),
|
||||
canvas_connect_nodes: z.object({ connections: z.array(z.object({ fromNodeId: z.string(), toNodeId: z.string() })).min(1) }),
|
||||
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() }),
|
||||
} satisfies Record<ToolName, z.AnyZodObject>;
|
||||
|
||||
export const toolDescriptions: Record<ToolName, string> = {
|
||||
canvas_get_state: "读取当前网页画布的节点、连线、选区和视口。",
|
||||
canvas_get_selection: "读取当前网页画布选中的节点。",
|
||||
canvas_export_snapshot: "导出当前画布快照,用于理解布局。",
|
||||
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: "批量创建文本节点,适合生成标题、段落、脚本、说明等内容块。",
|
||||
canvas_create_config_node: "创建生成配置节点,可指定 text/image/video/audio 模式和生成参数,可选择立即触发生成。",
|
||||
canvas_create_image_prompt_flow: "创建提示词文本节点和图片生成配置节点,并自动连线,可选择立即触发生图。",
|
||||
canvas_create_generation_flow: "创建通用生成流程:提示词文本节点、生成配置节点、参考节点连线,可用于文案、生图、视频或音频。",
|
||||
canvas_generate_text: "创建通用文本生成流程并立即触发生成。",
|
||||
canvas_generate_image: "创建通用图片生成流程并立即触发生成。",
|
||||
canvas_generate_video: "创建通用视频生成流程并立即触发生成。",
|
||||
canvas_generate_audio: "创建通用音频生成流程并立即触发生成。",
|
||||
canvas_update_node: "更新节点基础字段或 metadata。",
|
||||
canvas_update_node_text: "更新文本节点内容和标题。",
|
||||
canvas_move_nodes: "移动一个或多个节点,支持绝对坐标或 dx/dy 偏移。",
|
||||
canvas_resize_node: "调整节点尺寸。",
|
||||
canvas_delete_nodes: "删除指定节点及相关连线。",
|
||||
canvas_connect_nodes: "批量连接节点。",
|
||||
canvas_select_nodes: "设置当前选中节点。",
|
||||
canvas_set_viewport: "调整画布视口。",
|
||||
canvas_run_generation: "触发指定节点生成,通常用于配置节点或文本/图片/视频/音频节点。",
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { toolInputSchemas, toolNames, type ToolName } from "./schemas.js";
|
||||
import type { CanvasNode, CanvasSnapshot } from "./types.js";
|
||||
|
||||
export function isToolName(name: unknown): name is ToolName {
|
||||
return typeof name === "string" && toolNames.includes(name as ToolName);
|
||||
}
|
||||
|
||||
export function parseToolInput(name: ToolName, input: unknown) {
|
||||
return toolInputSchemas[name].parse(input ?? {});
|
||||
}
|
||||
|
||||
export function compactCanvasState(state: CanvasSnapshot | null) {
|
||||
if (!state) throw new Error("当前没有已连接画布");
|
||||
return { ...state, nodes: (state.nodes || []).map(compactNode) };
|
||||
}
|
||||
|
||||
export function compactNode(node: CanvasNode) {
|
||||
const metadata = { ...(node.metadata || {}) };
|
||||
if (typeof metadata.content === "string" && metadata.content.length > 240) metadata.content = `${metadata.content.slice(0, 120)}...`;
|
||||
return { id: node.id, type: node.type, title: node.title, position: node.position, width: node.width, height: node.height, metadata };
|
||||
}
|
||||
|
||||
export function nextCanvasX(state: CanvasSnapshot | null) {
|
||||
const nodes = state?.nodes || [];
|
||||
return nodes.length ? Math.max(...nodes.map((node) => node.position.x + node.width)) + 80 : 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type Position = { x: number; y: number };
|
||||
export type Viewport = { x: number; y: number; k: number };
|
||||
export type CanvasNodeType = "image" | "text" | "config" | "video" | "audio";
|
||||
export type CanvasNode = { id: string; type: CanvasNodeType; title?: string; position: Position; width: number; height: number; metadata?: Record<string, unknown> };
|
||||
export type CanvasConnection = { id: string; fromNodeId: string; toNodeId: string };
|
||||
export type CanvasSnapshot = { projectId?: string; title?: string; nodes?: CanvasNode[]; connections?: CanvasConnection[]; selectedNodeIds?: string[]; viewport?: Viewport; clientId?: string };
|
||||
export type AgentEmit = (type: string, payload: unknown) => void;
|
||||
export type AgentAttachment = { name?: string; type?: string; dataUrl?: string };
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port string `env:"PORT" envDefault:"8080"`
|
||||
AdminUsername string `env:"ADMIN_USERNAME" envDefault:"admin"`
|
||||
AdminPassword string `env:"ADMIN_PASSWORD" envDefault:"infinite-canvas"`
|
||||
JWTSecret string `env:"JWT_SECRET" envDefault:"infinite-canvas"`
|
||||
JWTExpireHours int `env:"JWT_EXPIRE_HOURS" envDefault:"168"`
|
||||
StorageDriver string `env:"STORAGE_DRIVER" envDefault:"sqlite"`
|
||||
DatabaseDSN string `env:"DATABASE_DSN" envDefault:"data/infinite-canvas.db"`
|
||||
PublicBaseURL string `env:"PUBLIC_BASE_URL"`
|
||||
LinuxDoAuthorizeURL string `env:"LINUX_DO_AUTHORIZE_URL" envDefault:"https://connect.linux.do/oauth2/authorize"`
|
||||
LinuxDoTokenURL string `env:"LINUX_DO_TOKEN_URL" envDefault:"https://connect.linux.do/oauth2/token"`
|
||||
LinuxDoUserInfoURL string `env:"LINUX_DO_USERINFO_URL" envDefault:"https://connect.linux.do/api/user"`
|
||||
}
|
||||
|
||||
var Cfg Config
|
||||
|
||||
func Load() error {
|
||||
_ = godotenv.Load()
|
||||
if err := env.Parse(&Cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
normalizeDockerSQLiteDSN("/app/data")
|
||||
if strings.TrimSpace(Cfg.JWTSecret) == "" || Cfg.JWTSecret == "infinite-canvas" {
|
||||
secret, err := randomSecret()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Cfg.JWTSecret = secret
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDockerSQLiteDSN(appDataDir string) {
|
||||
driver := strings.ToLower(strings.TrimSpace(Cfg.StorageDriver))
|
||||
if driver != "" && driver != "sqlite" {
|
||||
return
|
||||
}
|
||||
dsn := strings.TrimSpace(Cfg.DatabaseDSN)
|
||||
if dsn == "" || dsn == ":memory:" || strings.HasPrefix(dsn, "file:") {
|
||||
return
|
||||
}
|
||||
pathPart, suffix := dsn, ""
|
||||
if index := strings.Index(dsn, "?"); index >= 0 {
|
||||
pathPart = dsn[:index]
|
||||
suffix = dsn[index:]
|
||||
}
|
||||
if filepath.IsAbs(pathPart) {
|
||||
return
|
||||
}
|
||||
slashPath := filepath.ToSlash(pathPart)
|
||||
if slashPath != "data" && !strings.HasPrefix(slashPath, "data/") {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(appDataDir); err != nil {
|
||||
return
|
||||
}
|
||||
Cfg.DatabaseDSN = filepath.Join(filepath.Dir(appDataDir), filepath.FromSlash(slashPath)) + suffix
|
||||
}
|
||||
|
||||
func randomSecret() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeDockerSQLiteDSNUsesMountedDataDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
appDataDir := filepath.Join(root, "data")
|
||||
if err := os.MkdirAll(appDataDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db?_pragma=busy_timeout(5000)"}
|
||||
|
||||
normalizeDockerSQLiteDSN(appDataDir)
|
||||
|
||||
want := filepath.Join(root, "data", "infinite-canvas.db") + "?_pragma=busy_timeout(5000)"
|
||||
if Cfg.DatabaseDSN != want {
|
||||
t.Fatalf("DatabaseDSN = %q, want %q", Cfg.DatabaseDSN, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDockerSQLiteDSNLeavesLocalPathWithoutMountedDataDir(t *testing.T) {
|
||||
Cfg = Config{StorageDriver: "sqlite", DatabaseDSN: "data/infinite-canvas.db"}
|
||||
|
||||
normalizeDockerSQLiteDSN(filepath.Join(t.TempDir(), "missing-data"))
|
||||
|
||||
if Cfg.DatabaseDSN != "data/infinite-canvas.db" {
|
||||
t.Fatalf("DatabaseDSN = %q, want relative local path", Cfg.DatabaseDSN)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
ports:
|
||||
- "3000:3000"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -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,43 +1,17 @@
|
||||
---
|
||||
title: 本地开发
|
||||
description: 前后端分开启动时的本地开发方式
|
||||
description: 前端优先的本地开发方式
|
||||
---
|
||||
|
||||
# 本地开发
|
||||
|
||||
如果你需要改代码,建议前后端分开启动。
|
||||
当前主应用以 `web/` 前端为主,AI 请求由浏览器前台直连用户自己的 OpenAI 兼容接口。
|
||||
|
||||
## 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` 目录执行:
|
||||
## 1. 启动前端
|
||||
|
||||
```bash
|
||||
cd web
|
||||
bun install
|
||||
bun run dev
|
||||
```
|
||||
|
||||
@@ -47,9 +21,11 @@ bun run dev
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
开发代理默认转发到 `http://127.0.0.1:8080`。如果你的后端端口不同,启动前设置 `API_BASE_URL`。
|
||||
## 2. 配置模型
|
||||
|
||||
## 4. 启动文档站
|
||||
打开右上角配置弹窗,填写自己的 `Base URL`、`API Key` 和模型名。第三方提示词由 Next.js route 拉取并缓存在运行实例内存中;WebDAV 可选择前端直连或 Next.js 转发。
|
||||
|
||||
## 3. 启动文档站
|
||||
|
||||
如果需要单独调整文档站,在 `docs` 目录执行:
|
||||
|
||||
@@ -60,5 +36,5 @@ bun run dev
|
||||
## 常见场景
|
||||
|
||||
- 改画布、页面和交互:主要看 `web/`
|
||||
- 改接口、业务逻辑和数据库:主要看仓库根目录下的 Go 代码
|
||||
- 改提示词缓存或 WebDAV 代理:主要看 `web/src/app/api/` 和 `web/src/app/webdav-proxy/`
|
||||
- 改文档站内容:主要看 `docs/content/docs/`
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"api-response",
|
||||
"system-settings",
|
||||
"backend-database",
|
||||
"canvas-data-structure"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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: 画布常用鼠标与键盘操作说明
|
||||
|
||||
- 拖入图片文件:上传图片到画布。
|
||||
- 导入图片按钮:从本地选择图片。
|
||||
- 素材库或我的素材:选择素材后插入画布。
|
||||
- 我的素材:选择素材后插入画布。
|
||||
|
||||
## 撤销和重做范围
|
||||
|
||||
|
||||
@@ -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 默认保存在浏览器本地;第三方提示词由 Next.js route 拉取后缓存在运行实例内存里,不需要额外挂载数据目录。
|
||||
|
||||
@@ -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 相关提示词仓库,由 Next.js route 拉取并缓存在当前运行实例内存中。
|
||||
|
||||
## 素材
|
||||
|
||||
@@ -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 可使用少量 Next.js route,AI 接口不经过项目后端代理。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 画布项目和“我的素材”目前只保存在浏览器本地,不会随账号同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合本地或个人使用,公网多人使用不安全。公网部署推荐使用后台渠道,把真实密钥保存在服务端配置中。
|
||||
- 服务器素材库目前主要保存 URL 或文本,暂未提供文件上传接口。
|
||||
- Seedance 本地参考图/视频上传依赖 `PUBLIC_BASE_URL`,如果服务部署在 localhost、内网或不可被火山访问的地址,火山无法拉取参考素材。
|
||||
- AI API Key 保存在浏览器本地,并由浏览器直接请求配置的 OpenAI 兼容接口;只适合个人或可信环境使用。
|
||||
- Seedance 本地参考视频/音频更建议使用公网可访问 URL;上游是否接受前端传入的本地数据取决于具体兼容接口。
|
||||
- Seedance 返回远程视频 URL 时,前端会尽量下载为本地 Blob 持久化;如果因 CORS 或网络限制无法下载,会保留远程 URL,后续是否可播放取决于上游 URL 的有效期。
|
||||
- 画布更适合桌面端使用,移动端触控体验还未系统完善。
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"render",
|
||||
"docker",
|
||||
"third-party-prompt-repositories",
|
||||
"[在线体验](https://infinite-canvas-cpco.onrender.com/)"
|
||||
"[在线体验](https://canvas.best/)"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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` 会通过 Next.js route 拉取并缓存在内存中。
|
||||
- 如果需要跨设备同步画布、素材和生成记录,可在配置弹窗中填写 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 实例重启而清空,下次访问会重新拉取。
|
||||
- 适合体验和演示,不适合长期保存正式数据。
|
||||
|
||||
如果要长期使用,建议升级 Render 付费实例并挂载 Persistent Disk,或改用 PostgreSQL。
|
||||
|
||||
## 管理员账号
|
||||
|
||||
默认管理员用户名:
|
||||
|
||||
```text
|
||||
admin
|
||||
```
|
||||
|
||||
管理员密码是在 Render 部署页面里填写的 `ADMIN_PASSWORD`。
|
||||
长期使用建议优先部署到 Vercel,或自行配置 WebDAV 同步浏览器本地数据。
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
---
|
||||
title: 本地 Agent 接入规划
|
||||
description: 规划通过本地 Canvas Agent、MCP 和侧边栏助手连接 Codex / Claude Code 操作画布
|
||||
---
|
||||
|
||||
# 本地 Agent 接入规划
|
||||
|
||||
本文档规划个人用户在访问线上画布网页时,如何连接自己电脑上的 Codex / Claude Code,并让 Agent 通过对话和工具调用操作当前画布。
|
||||
|
||||
## 目标
|
||||
|
||||
- 用户打开线上画布后,可以启动本机服务连接当前画布。
|
||||
- 用户可以在 Codex 终端里通过 MCP 操作画布。
|
||||
- 用户也可以在网页侧边栏里和本地 Codex / Claude Code 对话。
|
||||
- 侧边栏需要展示普通消息、流式输出、工具调用、工具结果和错误提示。
|
||||
- 线上服务不保存用户本地 Codex / Claude Code 登录态、API Key 或本地文件权限。
|
||||
|
||||
## 核心结论
|
||||
|
||||
浏览器页面不能直接启动本地进程,也不应该直接控制本机 Codex / Claude Code。推荐增加一个用户本机运行的 `canvas-agent` 服务:
|
||||
|
||||
```txt
|
||||
线上画布网页 <-> 本机 canvas-agent <-> Codex / Claude Code
|
||||
|
|
||||
+-> MCP Server
|
||||
+-> 画布连接
|
||||
```
|
||||
|
||||
`canvas-agent` 是本地可信边界,负责启动或连接 Codex / Claude Code、暴露 MCP 工具、维护画布连接、转发侧边栏对话事件。
|
||||
|
||||
## 推荐目录
|
||||
|
||||
后续实现时建议在项目根目录新增独立 npm 包:
|
||||
|
||||
```txt
|
||||
canvas-agent/
|
||||
package.json
|
||||
tsconfig.json
|
||||
src/
|
||||
index.ts
|
||||
config.ts
|
||||
http-server.ts
|
||||
canvas-session.ts
|
||||
mcp-server.ts
|
||||
agents.ts
|
||||
schemas.ts
|
||||
tools.ts
|
||||
types.ts
|
||||
```
|
||||
|
||||
原因:
|
||||
|
||||
- 这是用户本机运行的 Node 服务,不属于线上服务端,也不属于纯前端页面。
|
||||
- 后续可以发布成 npm 包,用户通过 `npx` 或全局安装启动。
|
||||
- Codex SDK、Codex MCP、Claude SDK 都更适合在本地 Node 进程中接入。
|
||||
- HTTP 路由使用 Express,MCP 协议层使用官方 `@modelcontextprotocol/sdk`,工具入参使用 `zod`,避免手写协议和松散 JSON。
|
||||
|
||||
## 本机启动方式
|
||||
|
||||
MVP 阶段优先提供 `npx`:
|
||||
|
||||
```bash
|
||||
npx -y @basketikun/canvas-agent
|
||||
```
|
||||
|
||||
在本仓库内开发调试时可直接运行:
|
||||
|
||||
```bash
|
||||
cd canvas-agent
|
||||
npm install
|
||||
npm run build
|
||||
node dist/index.js
|
||||
```
|
||||
|
||||
启动后输出:
|
||||
|
||||
```txt
|
||||
Infinite Canvas Agent
|
||||
Local URL: http://127.0.0.1:17371
|
||||
Connect token: xxxxxx
|
||||
```
|
||||
|
||||
网页侧边栏填写或自动发现 `http://127.0.0.1:17371` 和 token 后连接本机服务。
|
||||
|
||||
## 前端需要增加的能力
|
||||
|
||||
前端不直接暴露 HTTP 接口给本机服务,优先由网页主动连接本机 Canvas Agent。当前 MVP 使用 SSE 接收本机事件,HTTP POST 上报画布状态和工具结果:
|
||||
|
||||
```txt
|
||||
网页 -> http://127.0.0.1:17371/events?token=xxx
|
||||
网页 -> http://127.0.0.1:17371/canvas/state?token=xxx
|
||||
网页 -> http://127.0.0.1:17371/canvas/result?token=xxx
|
||||
```
|
||||
|
||||
前端需要提供一个画布 Agent 控制层,内部调用现有 store / hook,不直接让外部操作 React 组件:
|
||||
|
||||
```ts
|
||||
canvasAgent.getState()
|
||||
canvasAgent.getSelection()
|
||||
canvasAgent.applyOps(ops)
|
||||
canvasAgent.focusNodes(ids)
|
||||
canvasAgent.exportSnapshot()
|
||||
```
|
||||
|
||||
建议先支持最小操作集:
|
||||
|
||||
- `add_node`:新增图片、文本、音频、视频、生成配置节点。
|
||||
- `update_node`:更新节点位置、尺寸、内容和配置。
|
||||
- `delete_node`:删除节点。
|
||||
- `connect_nodes`:连接两个节点。
|
||||
- `set_viewport`:移动或缩放当前视口。
|
||||
- `select_nodes`:选中节点。
|
||||
|
||||
工具入参应使用画布业务 JSON,不使用模拟鼠标点击或屏幕坐标自动化。
|
||||
|
||||
MCP 读取画布状态时默认返回摘要,不直接把完整图片、视频、音频或超长 base64 内容塞给 Agent。
|
||||
|
||||
## MCP 工具设计
|
||||
|
||||
`canvas-agent` 内置 MCP Server,让 Codex CLI 可以连接:
|
||||
|
||||
```txt
|
||||
Codex CLI <-> canvas-agent MCP <-> 当前网页画布
|
||||
```
|
||||
|
||||
建议首批 MCP 工具:
|
||||
|
||||
- `canvas_get_state`:读取当前画布节点、连线、选区和视口摘要。
|
||||
- `canvas_apply_ops`:批量执行画布操作。
|
||||
- `canvas_get_selection`:读取当前选中的节点。
|
||||
- `canvas_export_snapshot`:导出当前画布快照,用于让 Agent 理解布局。
|
||||
- `canvas_create_text_node`:快捷创建文本节点。
|
||||
- `canvas_create_image_prompt_flow`:快捷创建提示词文本节点和图片生成配置节点。
|
||||
|
||||
用户本机 Codex 配置示例:
|
||||
|
||||
```bash
|
||||
codex mcp add infinite-canvas -- npx -y @basketikun/canvas-agent mcp
|
||||
```
|
||||
|
||||
本仓库调试时可使用,实际配置建议替换为本机绝对路径:
|
||||
|
||||
```bash
|
||||
codex mcp add infinite-canvas -- node /path/to/infinite-canvas/canvas-agent/dist/index.js mcp
|
||||
```
|
||||
|
||||
网页侧边栏助手另走 Canvas Agent 的对话通道。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并续用同一个 thread,启动时会注入 `infinite-canvas` MCP 配置并把 `default_tools_approval_mode` 设为 `approve`,避免 Codex 自己的 MCP 审批卡住侧边栏;真正修改画布前由网页侧边栏做二次确认。
|
||||
|
||||
侧边栏图片附件通过 HTTP 发送到本机 Canvas Agent,Canvas Agent 临时写入本机文件后作为 app-server `localImage` 输入传给 Codex;MVP 会在输入区提示附件体积,单次请求体限制为 30MB。
|
||||
|
||||
## 侧边栏助手设计
|
||||
|
||||
侧边栏助手连接 `canvas-agent` 后,由 Canvas Agent 选择 Agent Adapter:
|
||||
|
||||
```txt
|
||||
Sidebar -> canvas-agent -> Codex app-server stdio
|
||||
Sidebar -> canvas-agent -> Claude Code CLI / Claude Agent SDK
|
||||
```
|
||||
|
||||
侧边栏优先展示 Codex app-server 原生结构化事件。前端只消费必要事件:
|
||||
|
||||
```ts
|
||||
type AgentEvent =
|
||||
| { type: "thread.started"; thread_id: string }
|
||||
| { type: "turn.started" }
|
||||
| { type: "item.started" | "item.updated" | "item.completed"; item: ThreadItem }
|
||||
| { type: "turn.completed"; usage: Usage }
|
||||
| { type: "turn.failed"; error: { message: string } };
|
||||
```
|
||||
|
||||
Claude 后续接入时再单独做 Claude Adapter;当前前端默认只展示 Codex。
|
||||
|
||||
## Codex 接入优先级
|
||||
|
||||
优先使用官方 `@openai/codex` CLI 的 `codex app-server --stdio`。
|
||||
|
||||
- Codex app-server 会输出 `item/agentMessage/delta`;Canvas Agent 转成 `item.updated` 后,前端用同一条消息做真实流式渲染。
|
||||
- 图片附件使用 app-server `localImage` 输入,不手写多模态协议。
|
||||
|
||||
参考:
|
||||
|
||||
- https://github.com/openai/codex/tree/main/codex-rs/app-server
|
||||
- https://developers.openai.com/codex/codex-manual.md
|
||||
|
||||
## Claude Code 接入优先级
|
||||
|
||||
Claude Code 侧当前 MVP 先调用本机 Claude Code CLI 的流式 JSON 输出,由 `canvas-agent` 统一转换事件和工具调用;后续可升级为 Claude Agent SDK。
|
||||
|
||||
如果希望 Claude Code 也能操作当前画布,需要给 Claude Code 配置同一个 MCP:
|
||||
|
||||
```bash
|
||||
claude mcp add --scope user --transport stdio infinite-canvas -- npx -y @basketikun/canvas-agent mcp
|
||||
```
|
||||
|
||||
Canvas Agent 调用 Claude Code 时默认允许 `mcp__infinite-canvas__*`,避免 print 模式被工具审批卡住;真正修改画布仍由网页侧边栏确认。
|
||||
|
||||
参考:
|
||||
|
||||
- https://docs.anthropic.com/en/docs/claude-code/sdk
|
||||
- https://code.claude.com/docs/en/agent-sdk/typescript
|
||||
|
||||
## 安全边界
|
||||
|
||||
- Canvas Agent 默认只监听 `127.0.0.1`。
|
||||
- Canvas Agent 启动时生成 token,网页连接必须携带 token。
|
||||
- Canvas Agent 只接受允许的 Origin,默认允许用户当前打开的画布域名。
|
||||
- 画布操作默认先在侧边栏展示工具调用,`canvas_apply_ops` 默认需要用户确认后执行,并保留最近一次工具操作撤销入口。
|
||||
- 不把 Codex / Claude 登录态、API Key、用户本地文件路径上传到线上服务。
|
||||
- 线上网页只保存 Canvas Agent 地址和必要偏好,不保存本地密钥。
|
||||
|
||||
## 实现阶段
|
||||
|
||||
### 第一阶段:Codex 终端操作画布
|
||||
|
||||
1. 新增 `canvas-agent/` npm 包。
|
||||
2. Canvas Agent 提供 SSE/HTTP 连接,网页连接并注册当前画布。
|
||||
3. 前端新增 `canvasAgent` 控制层。
|
||||
4. Canvas Agent 内置 MCP Server。
|
||||
5. Codex 通过 MCP 调用 `canvas_get_state` 和 `canvas_apply_ops`。
|
||||
|
||||
### 第二阶段:网页侧边栏连接 Codex
|
||||
|
||||
1. 前端新增本地 Agent 侧边栏。
|
||||
2. Canvas Agent 通过官方 `@openai/codex` CLI 的 `codex app-server --stdio` 接入 Codex,复用同一个 thread,注入 `infinite-canvas` MCP,并展示结构化事件流。
|
||||
3. 运行日志只保留关键 Codex 事件,避免把流式中间更新刷满日志。
|
||||
4. 侧边栏展示消息流、工具调用、工具结果、错误和图片附件大小提示。
|
||||
|
||||
### 第三阶段:接入 Claude Code
|
||||
|
||||
1. Canvas Agent 新增 Claude Code CLI Adapter。
|
||||
2. 统一 Claude Code 的消息和工具调用事件。
|
||||
3. 前端侧边栏增加 Agent 类型选择。
|
||||
|
||||
### 第四阶段:体验完善
|
||||
|
||||
1. 增加本机连接状态、重连和 token 更新。
|
||||
2. 优化工具调用确认、撤销和批量工具队列体验。
|
||||
3. 增加常用画布动作模板。
|
||||
4. 增加 npm 发布和用户安装文档。
|
||||
|
||||
## MVP 验收标准
|
||||
|
||||
- 用户运行 `npx -y @basketikun/canvas-agent` 后,线上画布能显示已连接。
|
||||
- 用户在 Codex CLI 中可以创建文本节点、移动节点、连接节点。
|
||||
- 网页侧边栏能发送一条消息给本地 Codex,并展示流式回复。
|
||||
- 网页侧边栏默认只展示本地 Codex,并展示结构化回复。
|
||||
- 侧边栏能展示一次 `canvas_apply_ops` 工具调用和结果。
|
||||
- 断开 Canvas Agent 后,网页能显示清晰的本机连接失败提示。
|
||||
@@ -4,6 +4,7 @@
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"[更新日志](/docs/progress/changelog)",
|
||||
"local-agent-integration-plan",
|
||||
"pending-test",
|
||||
"todo"
|
||||
]
|
||||
|
||||
@@ -5,51 +5,3 @@ description: 当前版本已实现但仍需人工验证的变更项
|
||||
|
||||
# 待测试
|
||||
|
||||
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
|
||||
- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 PNG mask 生成新图片节点;结果节点会放在原图右侧并自动连线,原图保持不变。
|
||||
- 图片节点悬浮工具栏新增“反推提示词”入口;点击后会在原图右侧创建包含反推预设提示词的文本节点,并继续创建一个已切到“文本”模式的生成配置节点,图片节点和文本节点都会自动连到该配置节点,配置节点会预填包含原图和文本节点引用的组装提示词并自动打开,用户可自行点击配置节点开始生成。
|
||||
- 图片节点悬浮工具栏新增“复制提示词”、“放大”和“超分”入口,并在末尾增加 `...` 更多按钮;点击“复制提示词”会复制生成该图片的提示词,图片没有提示词时会提示暂无可复制内容;点击 `...` 后打开 Ant Design 风格的“自定义工具栏”弹窗,可在图片节点占位上预览悬浮工具栏,信息、删除、存素材、下载、编辑和图片工具都在同一个快捷工具列表中勾选配置,并可切换是否显示按钮文字,保存后写入本地配置;`...` 配置入口固定显示,预览工具栏下方提供常驻横向滚动控制条。“放大”可在弹窗中选择 1K/2K/4K 目标像素和高清插值、双线性、最近邻算法,按原图比例生成新图片节点,已达到的目标像素会禁用并提示无需放大,最高不超过 4K;“超分”当前只打开暂未实现弹窗。
|
||||
- Canvas 资源节点会按当前生成上下文显示 `图片1`、`视频1`、`音频1`、`文本1` 角标;文本节点内容框和节点底部 prompt 面板输入 `@` 时应弹出已连接资源选择器,点击缩略图或文字可插入纯文本编号,并以蓝色 token 视觉高亮。
|
||||
- 文本节点连接到生成配置节点时,`@` 候选和实际生成输入应读取该生成配置节点的上游参考资源;生成配置输入统计区域应可拖动整个配置节点,预览按钮和设置控件仍保持可点击。
|
||||
- 配置弹窗改为“配置与用户偏好”并放大为可滚动弹窗;本地直连支持配置生图、视频、文本、音频四类可选模型列表和默认模型,新建画布生图和配置节点会读取“画布默认生图张数”,需要验证远程渠道和本地直连模型列表都能正确显示。
|
||||
- 画布音频节点底部生成面板改为音频提示词、音频模型下拉和 OpenAI Speech 参数设置,支持 `voice`、`response_format`、`speed`、`instructions` 并通过 `/audio/speech` 生成音频节点;需要验证本地直连和云端渠道的生成、重试、下载和刷新恢复。
|
||||
- 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。
|
||||
- 文档站搜索改为中英文混合 tokenizer,中文正文、标题和短语会按中文词、单字、二元和三元片段建立索引;需要验证 `/api/search` 和搜索弹窗能命中文档中的中文关键词。
|
||||
- 文档站改为 Next.js standalone server 输出,新增 `docs/Dockerfile`、`docs/docker-compose.yml` 和 `docs/docker-compose.local.yml` 独立运行入口;需要验证文档页、搜索接口和 LLM 文本接口在文档站容器中可访问。
|
||||
- 画布连线支持右键打开删除菜单;拖拽连线到目标卡片内部、连接点附近或卡片边缘外扩范围内会自动吸附并连接,拖到已有但不可连接的节点附近不会再误弹创建节点菜单。
|
||||
- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl` 和 `apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。
|
||||
- 生图工作台和画布生图会把参考图按当前顺序显示为 `图片1`、`图片2` 等编号,并在图生图请求的实际提示词中注入编号说明;需要验证 `/image` 参考图排序、画布配置节点输入顺序和画布助手参考图编号一致。
|
||||
- GPT Image 生图请求会在前端把 `9:16`、`16:9` 等比例转换成合法 `WIDTHxHEIGHT` 尺寸,并在非法尺寸时直接显示中文错误,避免上游返回 `invalid_value Invalid size`。
|
||||
- Docker 部署时,`DATABASE_DSN=data/infinite-canvas.db` 会在存在 `/app/data` 挂载目录时自动归一到 `/app/data/infinite-canvas.db`,需要验证后台模型配置不会再因为工作目录变为 `/app/web` 而读到空库。
|
||||
- Seedance 参考视频被火山判定包含真人或隐私信息时,前端错误摘要会提示改用不含真人的视频、官方允许的模型产物或已授权的 `asset://` 素材;参考素材上传目录改为跟随 SQLite 数据目录,并补充公开素材的 HEAD 访问。
|
||||
- Seedance 参考素材失败原因排查:后端会把火山上游错误摘要返回给前端;`/video` 和画布视频生成会按 `图片1/视频1/音频1` 自动编号参考素材,并在实际请求提示词中注入编号说明;参考视频会在请求前校验大小、时长、宽高、宽高比和像素总量。
|
||||
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
|
||||
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
|
||||
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
|
||||
- 修复删除画布图片节点或清空画布后撤销时,节点信息恢复但本地图片数据已被清理导致图片丢失的问题。
|
||||
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
|
||||
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
||||
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
||||
- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。
|
||||
- 生图工作台生成记录会过滤空缩略图地址,避免历史记录卡片渲染 `src=""` 图片。
|
||||
- 图像设置面板新增默认开启的“16倍数对齐”尺寸 toggle;开启时手动输入宽高并失焦、回车或点击浮层外关闭后,会把非 16 倍数的宽高向上补齐。
|
||||
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
||||
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
|
||||
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
||||
- 管理后台新增/编辑渠道时,渠道可用模型支持通过弹窗按“新获取、已有”分组选择,并可在弹窗内手动增加模型或拉取模型列表后再写回表单。
|
||||
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
|
||||
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
|
||||
- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name`、`preset=normal`、`input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。
|
||||
- 画布视频设置浮层改为挂载到页面根层级并使用自建浮层交互,避免被节点悬浮工具栏遮挡或点击面板内容时关闭。
|
||||
- 画布生成配置节点的生图参数改为复用图像设置浮层,支持在同一个入口里调整质量、尺寸和生成张数。
|
||||
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
|
||||
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`。
|
||||
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数。
|
||||
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
|
||||
- 火山方舟 Agent Plan / Seedance 2.0 视频生成需要在真实账号下验证:`/contents/generations/tasks` 创建任务、轮询状态、`content.video_url` 回填画布,以及 401/403/429/超时错误提示。
|
||||
- 管理后台保存私有渠道后,需要验证所有已启用渠道里的模型会自动出现在公开 `availableModels`,并且 `defaultVideoModel`、`defaultImageModel`、`defaultTextModel` 在为空或失效时会自动修复,前台不再显示旧的 `grok` 默认值。
|
||||
- `/video` 和画布视频设置已按 Seedance 2.0 增加分辨率、比例、4-15 秒/智能时长、生成声音和水印参数;需要在真实浏览器里验证参数回填、生成记录和画布节点配置都能保持一致。
|
||||
- `/video` 支持最多 9 张参考图、3 个参考视频、3 个参考音频;需要验证格式、大小、音频时长提示和生成请求中的 `reference_image`、`reference_video`、`reference_audio` 组装。
|
||||
- 画布新增音频节点,支持上传、拖入、播放、移动、缩放、删除,并可作为上游参考音频参与 Seedance 视频生成;需要验证刷新后本地音频 URL 能恢复。
|
||||
- `PUBLIC_BASE_URL` 已配置公网域名时,需要验证本地上传参考视频和参考音频能被火山拉取;未配置或配置为内网地址时,需要验证前端能给出明确提示。
|
||||
- Seedance 返回远程视频 URL 但浏览器无法下载为 Blob 时,需要验证视频节点刷新后仍保留远程 URL,并确认上游 URL 有效期限制。
|
||||
|
||||
@@ -6,3 +6,5 @@ description: 当前项目后续值得处理的事项
|
||||
# TODO
|
||||
|
||||
本文档用来记录当前项目后续比较值得处理的事项。
|
||||
|
||||
- 本地 Agent 接入后续:按[本地 Agent 接入规划](/docs/progress/local-agent-integration-plan)把 Claude Code CLI Adapter 升级为 Claude Agent SDK Adapter,并完善工具队列体验。
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"title": "赞助支持",
|
||||
"title": "支持与安全",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"security",
|
||||
"donate",
|
||||
"sponsor"
|
||||
]
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
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、没有实际利用路径的安全头建议、纯社工钓鱼或账号找回问题通常不属于本项目漏洞范围。
|
||||
|
||||
+6
-8
@@ -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/business/license)
|
||||
- [贡献者协议](/docs/business/cla)
|
||||
- [商务合作](/docs/business/business)
|
||||
|
||||
## 赞助支持
|
||||
## 支持与安全
|
||||
|
||||
- [文档](/docs/support/docs)
|
||||
- [漏洞提交](/docs/support/security)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
|
||||
@@ -40,5 +38,5 @@
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不支持云同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,跨设备可自行配置 WebDAV 同步。
|
||||
- AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ArrowUpRight, BookOpen, Rocket } from 'lucide-react';
|
||||
import { appName, gitConfig } from '@/lib/shared';
|
||||
|
||||
const githubUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}`;
|
||||
const demoUrl = 'https://infinite-canvas-cpco.onrender.com/';
|
||||
const demoUrl = 'https://canvas.best/';
|
||||
const starHistoryUrl = `https://www.star-history.com/?repos=${gitConfig.user}%2F${gitConfig.repo}&type=date`;
|
||||
const starHistoryChart = `https://api.star-history.com/chart?repos=${gitConfig.user}/${gitConfig.repo}&type=date&transparent=true`;
|
||||
const darkStarHistoryChart = `${starHistoryChart}&theme=dark`;
|
||||
|
||||
@@ -28,7 +28,7 @@ export function baseOptions(): BaseLayoutProps {
|
||||
<ArrowUpRight className="size-4" />
|
||||
</span>
|
||||
),
|
||||
url: 'https://infinite-canvas-cpco.onrender.com/',
|
||||
url: 'https://canvas.best/',
|
||||
external: true,
|
||||
on: 'nav',
|
||||
},
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
+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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { deleteAdminCreditLog, fetchAdminCreditLogs, saveAdminCreditLog, type AdminCreditLog } from "@/services/api/admin";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const defaultPageSize = 10;
|
||||
|
||||
export function useAdminCreditLogs() {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const clearSession = useUserStore((state) => state.clearSession);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "credit-logs", token, keyword, page, pageSize],
|
||||
queryFn: () => fetchAdminCreditLogs(token, { keyword, page, pageSize }),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (log: Partial<AdminCreditLog>) => saveAdminCreditLog(token, log),
|
||||
onSuccess: async (_, log) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "credit-logs"] });
|
||||
message.success(log.id ? "日志已保存" : "日志已新增");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminCreditLog(token, id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "credit-logs"] });
|
||||
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; page: number; pageSize: number }>) => {
|
||||
const queryState = { keyword, page, pageSize, ...next };
|
||||
if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1;
|
||||
setKeyword(queryState.keyword);
|
||||
setPage(queryState.page);
|
||||
setPageSize(queryState.pageSize);
|
||||
};
|
||||
|
||||
const data = query.data;
|
||||
|
||||
return {
|
||||
logs: data?.items || [],
|
||||
keyword,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending,
|
||||
searchLogs: (value = keyword) => updateFilters({ keyword: value }),
|
||||
changePage: (value: number) => updateFilters({ page: value }),
|
||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
|
||||
refreshLogs: () => query.refetch(),
|
||||
saveLog: (log: Partial<AdminCreditLog>) => saveMutation.mutateAsync(log),
|
||||
deleteLog: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FileTextOutlined, HomeOutlined, LogoutOutlined, PictureOutlined, SettingOutlined, TransactionOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { Button, Flex, Layout, Menu, Typography, theme } from "antd";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
||||
import { adminLayoutStyle } from "@/lib/app-theme";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const adminMenus = [
|
||||
{ key: "/admin/users", icon: <UserOutlined />, label: "用户管理" },
|
||||
{ key: "/admin/credit-logs", icon: <TransactionOutlined />, label: "算力点日志" },
|
||||
{ key: "/admin/prompts", icon: <FileTextOutlined />, label: "提示词管理" },
|
||||
{ key: "/admin/assets", icon: <PictureOutlined />, label: "素材库" },
|
||||
{ key: "/admin/settings", icon: <SettingOutlined />, label: "系统设置" },
|
||||
];
|
||||
|
||||
export default function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const { token: antToken } = theme.useToken();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const user = useUserStore((state) => state.user);
|
||||
const isReady = useUserStore((state) => state.isReady);
|
||||
const logout = useUserStore((state) => state.clearSession);
|
||||
const activeKey = pathname.startsWith("/admin/settings")
|
||||
? "/admin/settings"
|
||||
: pathname.startsWith("/admin/assets")
|
||||
? "/admin/assets"
|
||||
: pathname.startsWith("/admin/prompts")
|
||||
? "/admin/prompts"
|
||||
: pathname.startsWith("/admin/credit-logs")
|
||||
? "/admin/credit-logs"
|
||||
: pathname.startsWith("/admin/users")
|
||||
? "/admin/users"
|
||||
: "";
|
||||
const pageTitle = pathname.startsWith("/admin/settings") ? "系统设置" : pathname.startsWith("/admin/assets") ? "素材库管理" : pathname.startsWith("/admin/prompts") ? "提示词管理" : pathname.startsWith("/admin/credit-logs") ? "算力点日志" : "用户管理";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) return;
|
||||
if (!token) {
|
||||
router.replace("/login?redirect=/admin");
|
||||
return;
|
||||
}
|
||||
if (user?.role !== "admin") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [isReady, router, token, user?.role]);
|
||||
|
||||
if (!isReady || !token || user?.role !== "admin") {
|
||||
return (
|
||||
<div style={{ display: "flex", minHeight: "100vh", alignItems: "center", justifyContent: "center", background: antToken.colorBgLayout }}>
|
||||
<span />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout hasSider style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgLayout }}>
|
||||
<Layout.Sider width={adminLayoutStyle.siderWidth} style={{ height: "100vh", overflow: "hidden", background: antToken.colorBgContainer, borderRight: `1px solid ${antToken.colorBorder}` }}>
|
||||
<Flex align="center" gap={12} style={{ height: adminLayoutStyle.brandHeight, padding: "0 20px", borderBottom: `1px solid ${antToken.colorBorderSecondary}` }}>
|
||||
<span aria-hidden style={{ display: "inline-block", width: 30, height: 30, background: antToken.colorText, WebkitMask: "url(/logo.svg) center / contain no-repeat", mask: "url(/logo.svg) center / contain no-repeat" }} />
|
||||
<Typography.Text strong style={{ fontSize: 18, letterSpacing: 0 }}>
|
||||
无限画布
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[activeKey]}
|
||||
style={adminLayoutStyle.menu}
|
||||
items={adminMenus.map((item) => ({
|
||||
...item,
|
||||
label: (
|
||||
<Link href={item.key} style={{ color: "inherit" }}>
|
||||
{item.label}
|
||||
</Link>
|
||||
),
|
||||
style: adminLayoutStyle.menuItem,
|
||||
}))}
|
||||
/>
|
||||
<Flex vertical gap={8} style={{ position: "absolute", bottom: 0, insetInline: 0, padding: 12, borderTop: `1px solid ${antToken.colorBorder}`, background: antToken.colorBgContainer }}>
|
||||
<Button block icon={<HomeOutlined />} href="/canvas" target="_blank" rel="noreferrer">
|
||||
前往画布
|
||||
</Button>
|
||||
<Button block icon={<LogoutOutlined />} onClick={logout}>
|
||||
退出登录
|
||||
</Button>
|
||||
</Flex>
|
||||
</Layout.Sider>
|
||||
<Layout style={{ background: antToken.colorBgLayout }}>
|
||||
<Layout.Header
|
||||
style={{ display: "flex", alignItems: "center", justifyContent: "space-between", height: adminLayoutStyle.headerHeight, padding: "0 24px", background: antToken.colorBgContainer, borderBottom: `1px solid ${antToken.colorBorder}` }}
|
||||
>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{pageTitle}
|
||||
</Typography.Title>
|
||||
<Flex align="center" gap={4}>
|
||||
<UserStatusActions showConfig={false} />
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{ minHeight: 0, overflow: "auto" }}>{children}</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AdminPage() {
|
||||
redirect("/admin/users");
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CopyOutlined, DeleteOutlined, EditOutlined, ExportOutlined, EyeOutlined, PlusOutlined, ReloadOutlined, SearchOutlined, SyncOutlined } 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, Table, Tag, Tooltip, Typography } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import type { Prompt } from "@/services/api/prompts";
|
||||
import { useAdminPrompts } from "./use-admin-prompts";
|
||||
|
||||
export default function AdminPromptsPage() {
|
||||
const {
|
||||
categories,
|
||||
prompts,
|
||||
tags,
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
isLoading,
|
||||
isSyncing,
|
||||
searchPrompts,
|
||||
changeCategory,
|
||||
changeTag,
|
||||
changePage,
|
||||
changePageSize,
|
||||
resetFilters,
|
||||
refreshPrompts,
|
||||
syncCategory,
|
||||
savePrompt: saveAdminPrompt,
|
||||
deletePrompt,
|
||||
deletePrompts,
|
||||
} = useAdminPrompts();
|
||||
const copyText = useCopyText();
|
||||
const [form] = Form.useForm<Partial<Prompt> & { tagText?: string }>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingPrompt, setEditingPrompt] = useState<Partial<Prompt> | null>(null);
|
||||
const [detailPrompt, setDetailPrompt] = useState<Prompt | null>(null);
|
||||
const [deletingPrompt, setDeletingPrompt] = useState<Prompt | null>(null);
|
||||
const [selectedPromptIds, setSelectedPromptIds] = useState<string[]>([]);
|
||||
const [isBatchDeleteOpen, setIsBatchDeleteOpen] = useState(false);
|
||||
const [isSyncOpen, setIsSyncOpen] = useState(false);
|
||||
const defaultCategory = categories[0]?.category || "";
|
||||
const categoryName = (category: string) => categories.find((item) => item.category === category)?.name || category;
|
||||
const categoryOptions = [{ label: "全部分类", value: "" }, ...categories.map((item) => ({ label: item.name, value: item.category }))];
|
||||
const tagOptions = tags.map((item) => ({ label: item, value: item }));
|
||||
|
||||
useEffect(() => {
|
||||
if (editingPrompt) form.setFieldsValue({ ...editingPrompt, tagText: editingPrompt.tags?.join(", ") || "" });
|
||||
}, [editingPrompt, form]);
|
||||
|
||||
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||
|
||||
const savePrompt = async () => {
|
||||
const value = await form.validateFields();
|
||||
await saveAdminPrompt({
|
||||
...editingPrompt,
|
||||
...value,
|
||||
category: value.category || defaultCategory,
|
||||
tags: (value.tagText || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
});
|
||||
setEditingPrompt(null);
|
||||
};
|
||||
|
||||
const batchDeletePrompts = async () => {
|
||||
await deletePrompts(selectedPromptIds);
|
||||
setSelectedPromptIds([]);
|
||||
setIsBatchDeleteOpen(false);
|
||||
};
|
||||
|
||||
const columns: ProColumns<Prompt>[] = [
|
||||
{
|
||||
title: "封面",
|
||||
dataIndex: "coverUrl",
|
||||
width: 88,
|
||||
render: (_, item) => <Image src={item.coverUrl || "/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={() => setDetailPrompt(item)}>
|
||||
{item.title}
|
||||
</Typography.Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "分类",
|
||||
dataIndex: "category",
|
||||
width: 150,
|
||||
render: (_, item) => <Typography.Text type="secondary">{categoryName(item.category)}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
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: "操作",
|
||||
key: "actions",
|
||||
width: 112,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="详情">
|
||||
<Button type="text" size="small" icon={<EyeOutlined />} onClick={() => setDetailPrompt(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑">
|
||||
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => setEditingPrompt(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingPrompt(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={() => searchPrompts(keywordText)} onChange={(event) => setKeywordText(event.target.value)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col flex="220px">
|
||||
<Form.Item label="分组">
|
||||
<Select value={category} onChange={changeCategory} options={categoryOptions} />
|
||||
</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={() => searchPrompts(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<Prompt>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={prompts}
|
||||
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 refreshPrompts() }}
|
||||
rowSelection={{ selectedRowKeys: selectedPromptIds, onChange: (keys) => setSelectedPromptIds(keys.map(String)) }}
|
||||
toolBarRender={() => [
|
||||
<Button key="batch-delete" danger icon={<DeleteOutlined />} disabled={!selectedPromptIds.length} onClick={() => setIsBatchDeleteOpen(true)}>
|
||||
批量删除{selectedPromptIds.length ? ` ${selectedPromptIds.length}` : ""}
|
||||
</Button>,
|
||||
<Button key="sync" icon={<SyncOutlined />} onClick={() => setIsSyncOpen(true)}>
|
||||
同步
|
||||
</Button>,
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingPrompt({ category: defaultCategory, 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={editingPrompt?.id ? "编辑提示词" : "新增提示词"} open={Boolean(editingPrompt)} width={720} onCancel={() => setEditingPrompt(null)} onOk={() => void savePrompt()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: "请输入标题" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="分类">
|
||||
<Select options={categories.map((item) => ({ label: item.name, value: item.category }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="coverUrl" label="封面 URL">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tagText" label="标签,用逗号分隔">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="prompt" label="提示词" rules={[{ required: true, message: "请输入提示词" }]}>
|
||||
<Input.TextArea rows={6} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="提示词详情" open={Boolean(detailPrompt)} width={760} onCancel={() => setDetailPrompt(null)} footer={<Button onClick={() => setDetailPrompt(null)}>关闭</Button>}>
|
||||
{detailPrompt ? (
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={14} align="start">
|
||||
<Image src={detailPrompt.coverUrl || "/logo.svg"} alt={detailPrompt.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 }}>
|
||||
{detailPrompt.title}
|
||||
</Typography.Title>
|
||||
<Space wrap>
|
||||
<Tag>{categoryName(detailPrompt.category)}</Tag>
|
||||
{(detailPrompt.tags || []).map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Flex>
|
||||
</Flex>
|
||||
{detailPrompt.preview ? (
|
||||
<Typography.Paragraph type="secondary" style={{ margin: 0 }}>
|
||||
{detailPrompt.preview}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
<Input.TextArea value={detailPrompt.prompt} rows={8} readOnly />
|
||||
<Space>
|
||||
<Button icon={<CopyOutlined />} onClick={() => copyText(detailPrompt.prompt)}>
|
||||
复制提示词
|
||||
</Button>
|
||||
{detailPrompt.githubUrl ? (
|
||||
<Button icon={<ExportOutlined />} href={detailPrompt.githubUrl} target="_blank">
|
||||
远程源
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="同步远程提示词源"
|
||||
open={isSyncOpen}
|
||||
width={640}
|
||||
onCancel={() => !isSyncing && setIsSyncOpen(false)}
|
||||
mask={{ closable: !isSyncing }}
|
||||
footer={
|
||||
<Button disabled={isSyncing} onClick={() => setIsSyncOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey="category"
|
||||
dataSource={categories.filter((item) => item.remote)}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "远程源",
|
||||
dataIndex: "name",
|
||||
render: (_, item) => (
|
||||
<Flex align="center" gap={8}>
|
||||
{item.name}
|
||||
{item.githubUrl ? (
|
||||
<Typography.Link href={item.githubUrl} target="_blank">
|
||||
<ExportOutlined />
|
||||
</Typography.Link>
|
||||
) : null}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "",
|
||||
key: "sync",
|
||||
width: 96,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isSyncing}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await syncCategory(item.category);
|
||||
setIsSyncOpen(false);
|
||||
} catch {}
|
||||
}}
|
||||
>
|
||||
同步
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除提示词"
|
||||
open={Boolean(deletingPrompt)}
|
||||
onCancel={() => setDeletingPrompt(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingPrompt) return;
|
||||
await deletePrompt(deletingPrompt.id);
|
||||
setDeletingPrompt(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除「{deletingPrompt?.title}」吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
|
||||
<Modal title="批量删除提示词" open={isBatchDeleteOpen} onCancel={() => setIsBatchDeleteOpen(false)} onOk={() => void batchDeletePrompts()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||
确定删除已选中的 {selectedPromptIds.length} 条提示词吗?删除后会从当前分类中删除。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { deleteAdminPrompt, deleteAdminPrompts, fetchAdminPrompts, fetchAdminPromptCategories, saveAdminPrompt, syncAdminPromptCategory, type AdminPromptCategory } from "@/services/api/admin";
|
||||
import type { Prompt } from "@/services/api/prompts";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const defaultPageSize = 10;
|
||||
|
||||
export function useAdminPrompts() {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const clearSession = useUserStore((state) => state.clearSession);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [tag, setTag] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ["admin", "prompt-categories", token],
|
||||
queryFn: () => fetchAdminPromptCategories(token),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const promptsQuery = useQuery({
|
||||
queryKey: ["admin", "prompts", token, keyword, category, tag, page, pageSize],
|
||||
queryFn: () => fetchAdminPrompts(token, { keyword, category, tag, page, pageSize }),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (category: string) => syncAdminPromptCategory(token, category),
|
||||
onSuccess: async (categories) => {
|
||||
queryClient.setQueryData<AdminPromptCategory[]>(["admin", "prompt-categories", token], categories);
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success("远程提示词源已同步");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "同步失败");
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (prompt: Partial<Prompt>) => saveAdminPrompt(token, prompt),
|
||||
onSuccess: async (_, prompt) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success(prompt.id ? "提示词已保存" : "提示词已新增");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "保存失败");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminPrompt(token, id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success("提示词已删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
const batchDeleteMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteAdminPrompts(token, ids),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompt-categories"] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "prompts"] });
|
||||
message.success("提示词已批量删除");
|
||||
},
|
||||
onError: (error) => {
|
||||
message.error(error instanceof Error ? error.message : "批量删除失败");
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const error = categoriesQuery.error || promptsQuery.error;
|
||||
if (!error) return;
|
||||
const errorMessage = error instanceof Error ? error.message : "读取提示词失败";
|
||||
message.error(errorMessage);
|
||||
if (errorMessage.includes("未登录") || errorMessage.includes("权限不足") || errorMessage.includes("登录状态无效")) clearSession();
|
||||
}, [categoriesQuery.error, clearSession, message, promptsQuery.error]);
|
||||
|
||||
const updateFilters = (next: Partial<{ keyword: string; category: string; tag: string[]; page: number; pageSize: number }>) => {
|
||||
const queryState = { keyword, category, tag, page, pageSize, ...next };
|
||||
if (next.keyword !== undefined || next.category !== undefined || next.tag !== undefined || next.pageSize !== undefined) queryState.page = 1;
|
||||
setKeyword(queryState.keyword);
|
||||
setCategory(queryState.category);
|
||||
setTag(queryState.tag);
|
||||
setPage(queryState.page);
|
||||
setPageSize(queryState.pageSize);
|
||||
};
|
||||
|
||||
const data = promptsQuery.data;
|
||||
|
||||
return {
|
||||
categories: categoriesQuery.data || [],
|
||||
prompts: data?.items || [],
|
||||
tags: data?.tags || [],
|
||||
keyword,
|
||||
category,
|
||||
tag,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: categoriesQuery.isFetching || promptsQuery.isFetching || saveMutation.isPending || deleteMutation.isPending || batchDeleteMutation.isPending,
|
||||
isSyncing: syncMutation.isPending,
|
||||
syncCategory: (category: string) => syncMutation.mutateAsync(category),
|
||||
searchPrompts: (value = keyword) => updateFilters({ keyword: value }),
|
||||
changeCategory: (value: string) => updateFilters({ category: value, tag: [] }),
|
||||
changeTag: (value: string[]) => updateFilters({ tag: value }),
|
||||
changePage: (value: number) => updateFilters({ page: value }),
|
||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||
resetFilters: () => updateFilters({ keyword: "", category: "", tag: [], page: 1, pageSize: defaultPageSize }),
|
||||
refreshPrompts: async () => {
|
||||
await categoriesQuery.refetch();
|
||||
await promptsQuery.refetch();
|
||||
},
|
||||
savePrompt: (prompt: Partial<Prompt>) => saveMutation.mutateAsync(prompt),
|
||||
deletePrompt: (id: string) => deleteMutation.mutateAsync(id),
|
||||
deletePrompts: (ids: string[]) => batchDeleteMutation.mutateAsync(ids),
|
||||
};
|
||||
}
|
||||
@@ -1,980 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircleOutlined, DeleteOutlined, FormatPainterOutlined, LoadingOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from "@ant-design/icons";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { App, Button, Card, Checkbox, Col, Drawer, Flex, Form, Input, InputNumber, Modal, Row, Segmented, Select, Space, Switch, Table, Tabs, Tag, Typography } from "antd";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { EditorView } from "@uiw/react-codemirror";
|
||||
|
||||
import { fetchAdminSettings, fetchChannelModels, saveAdminSettings, testChannelModel, type AdminModelChannel, type AdminModelCost, type AdminSettings } from "@/services/api/admin";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const CodeMirror = dynamic(() => import("@uiw/react-codemirror"), { ssr: false });
|
||||
const jsonEditorTheme = EditorView.theme({
|
||||
"&": { backgroundColor: "var(--ant-color-bg-container)", color: "var(--ant-color-text)" },
|
||||
".cm-content": { caretColor: "var(--ant-color-text)", padding: "12px 0" },
|
||||
".cm-line": { padding: "0 18px" },
|
||||
".cm-gutters": { backgroundColor: "var(--ant-color-fill-quaternary)", borderRight: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" },
|
||||
".cm-activeLine": { backgroundColor: "var(--ant-color-fill-quaternary)" },
|
||||
".cm-activeLineGutter": { backgroundColor: "var(--ant-color-fill-quaternary)", color: "var(--ant-color-text)" },
|
||||
".cm-cursor": { borderLeftColor: "var(--ant-color-text)" },
|
||||
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { backgroundColor: "var(--ant-control-item-bg-active)" },
|
||||
".cm-foldPlaceholder": { backgroundColor: "var(--ant-color-fill-quaternary)", border: "1px solid var(--ant-color-border)", color: "var(--ant-color-text-tertiary)" },
|
||||
"&.cm-focused": { outline: "none" },
|
||||
});
|
||||
|
||||
const emptySettings: AdminSettings = {
|
||||
public: {
|
||||
modelChannel: {
|
||||
availableModels: [],
|
||||
modelCosts: [],
|
||||
defaultModel: "",
|
||||
defaultImageModel: "",
|
||||
defaultVideoModel: "",
|
||||
defaultTextModel: "",
|
||||
systemPrompt: "",
|
||||
allowCustomChannel: true,
|
||||
},
|
||||
auth: { allowRegister: true, linuxDo: { enabled: false } },
|
||||
},
|
||||
private: { channels: [], promptSync: { enabled: true, cron: "*/5 * * * *" }, auth: { linuxDo: { clientId: "", clientSecret: "" } } },
|
||||
};
|
||||
const emptyChannel: AdminModelChannel = { protocol: "openai", name: "", baseUrl: "", apiKey: "", models: [], weight: 1, enabled: true, remark: "" };
|
||||
|
||||
type SettingsTabKey = "public" | "private";
|
||||
type EditorMode = "visual" | "json";
|
||||
type ModelSelectTabKey = "new" | "current";
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const token = useUserStore((state) => state.token);
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<AdminSettings>();
|
||||
const [activeTab, setActiveTab] = useState<SettingsTabKey>("public");
|
||||
const [editorMode, setEditorMode] = useState<Record<SettingsTabKey, EditorMode>>({ public: "visual", private: "visual" });
|
||||
const [jsonText, setJsonText] = useState<Record<SettingsTabKey, string>>({ public: "", private: "" });
|
||||
const [channels, setChannels] = useState<AdminModelChannel[]>([]);
|
||||
const [channelForm] = Form.useForm<AdminModelChannel>();
|
||||
const [editingChannelIndex, setEditingChannelIndex] = useState<number | null>(null);
|
||||
const [isChannelDrawerOpen, setIsChannelDrawerOpen] = useState(false);
|
||||
const [testChannelIndex, setTestChannelIndex] = useState<number | null>(null);
|
||||
const [testKeyword, setTestKeyword] = useState("");
|
||||
const [selectedTestModels, setSelectedTestModels] = useState<string[]>([]);
|
||||
const [testingModels, setTestingModels] = useState<string[]>([]);
|
||||
const [testResults, setTestResults] = useState<Record<string, { status: "success" | "error"; duration?: string; message: string }>>({});
|
||||
const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false);
|
||||
const [modelSelectSource, setModelSelectSource] = useState<string[]>([]);
|
||||
const [modelSelectExisting, setModelSelectExisting] = useState<string[]>([]);
|
||||
const [modelSelectSelected, setModelSelectSelected] = useState<string[]>([]);
|
||||
const [modelSelectKeyword, setModelSelectKeyword] = useState("");
|
||||
const [modelSelectNewModel, setModelSelectNewModel] = useState("");
|
||||
const [modelSelectTab, setModelSelectTab] = useState<ModelSelectTabKey>("new");
|
||||
const [isFetchingChannelModels, setIsFetchingChannelModels] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [modelCosts, setModelCosts] = useState<AdminModelCost[]>([]);
|
||||
const [knownModels, setKnownModels] = useState<string[]>([]);
|
||||
const publicModels = Form.useWatch(["public", "modelChannel", "availableModels"], form) || [];
|
||||
const channelModels = useMemo(() => collectChannelModels(channels), [channels]);
|
||||
const channelTableData = useMemo(() => channels.map((channel, index) => ({ ...channel, _index: index, _rowKey: `${index}-${channel.name}-${channel.baseUrl}` })), [channels]);
|
||||
const activeMode = editorMode[activeTab];
|
||||
const activeJsonText = jsonText[activeTab];
|
||||
const jsonError = activeMode === "json" ? getJsonError(activeJsonText) : "";
|
||||
const modelSelectGroups = useMemo(() => buildModelSelectGroups(modelSelectSource, modelSelectExisting), [modelSelectSource, modelSelectExisting]);
|
||||
const activeModelSelectModels = useMemo(() => {
|
||||
const keyword = modelSelectKeyword.trim().toLowerCase();
|
||||
return modelSelectGroups[modelSelectTab].filter((model) => model.toLowerCase().includes(keyword));
|
||||
}, [modelSelectGroups, modelSelectKeyword, modelSelectTab]);
|
||||
const activeSelectedCount = activeModelSelectModels.filter((model) => modelSelectSelected.includes(model)).length;
|
||||
|
||||
const loadSettings = async () => {
|
||||
if (!token) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = normalizeSettings(await fetchAdminSettings(token));
|
||||
form.setFieldsValue(data);
|
||||
setChannels(data.private.channels);
|
||||
setModelCosts(data.public.modelChannel.modelCosts);
|
||||
setKnownModels(collectKnownModels(data));
|
||||
setJsonText({
|
||||
public: JSON.stringify(data.public, null, 2),
|
||||
private: JSON.stringify(data.private, null, 2),
|
||||
});
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取设置失败");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadSettings();
|
||||
}, [token]);
|
||||
|
||||
const changeTab = (nextTab: SettingsTabKey) => {
|
||||
setActiveTab(nextTab);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!token) return;
|
||||
const values = await collectSettings(form, editorMode, jsonText, message);
|
||||
if (!values) {
|
||||
return;
|
||||
}
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const saved = normalizeSettings(await saveAdminSettings(token, values));
|
||||
const merged = mergeChannelApiKeys(values.private.channels, saved);
|
||||
form.setFieldsValue(merged);
|
||||
setChannels(merged.private.channels);
|
||||
setModelCosts(merged.public.modelChannel.modelCosts);
|
||||
rememberKnownModels(merged);
|
||||
setJsonText({
|
||||
public: JSON.stringify(merged.public, null, 2),
|
||||
private: JSON.stringify(merged.private, null, 2),
|
||||
});
|
||||
message.success("已保存");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存失败");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleMode = (tab: SettingsTabKey, nextMode: EditorMode) => {
|
||||
if (nextMode === "json") {
|
||||
setJsonText((current) => ({
|
||||
...current,
|
||||
[tab]: JSON.stringify(tab === "public" ? normalizePublicSetting(form.getFieldValue(["public"]) as Partial<AdminSettings["public"]>) : normalizePrivateSetting(form.getFieldValue(["private"]) as Partial<AdminSettings["private"]>), null, 2),
|
||||
}));
|
||||
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
|
||||
return;
|
||||
}
|
||||
const parsed = parseTabJson(tab, jsonText[tab]);
|
||||
if (!parsed) {
|
||||
message.error("JSON 格式不正确");
|
||||
return;
|
||||
}
|
||||
form.setFieldsValue({ [tab]: parsed } as Partial<AdminSettings>);
|
||||
if (tab === "private") setChannels((parsed as AdminSettings["private"]).channels);
|
||||
if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts);
|
||||
rememberKnownModels({ ...normalizeSettings(form.getFieldsValue(true) as AdminSettings), [tab]: parsed });
|
||||
setEditorMode((current) => ({ ...current, [tab]: nextMode }));
|
||||
};
|
||||
|
||||
const formatJson = (tab: SettingsTabKey) => {
|
||||
const parsed = parseTabJson(tab, jsonText[tab]);
|
||||
if (!parsed) {
|
||||
message.error("JSON 格式不正确");
|
||||
return;
|
||||
}
|
||||
if (tab === "public") setModelCosts((parsed as AdminSettings["public"]).modelChannel.modelCosts);
|
||||
setJsonText((current) => ({
|
||||
...current,
|
||||
[tab]: JSON.stringify(parsed, null, 2),
|
||||
}));
|
||||
};
|
||||
|
||||
const openChannelDrawer = (index: number | null) => {
|
||||
setEditingChannelIndex(index);
|
||||
setIsChannelDrawerOpen(true);
|
||||
const channel = index === null ? emptyChannel : normalizeChannel(channels[index]);
|
||||
channelForm.setFieldsValue(channel);
|
||||
rememberModels(channel.models);
|
||||
};
|
||||
|
||||
const closeChannelDrawer = () => {
|
||||
setIsChannelDrawerOpen(false);
|
||||
setEditingChannelIndex(null);
|
||||
channelForm.resetFields();
|
||||
};
|
||||
|
||||
const saveChannel = async () => {
|
||||
const channel = normalizeChannel(await channelForm.validateFields());
|
||||
rememberModels(channel.models);
|
||||
const nextChannels = [...channels];
|
||||
if (editingChannelIndex === null) nextChannels.push(channel);
|
||||
else nextChannels[editingChannelIndex] = channel;
|
||||
await persistChannels(nextChannels);
|
||||
closeChannelDrawer();
|
||||
};
|
||||
|
||||
const fetchChannelModelList = async () => {
|
||||
if (!token) return;
|
||||
const channel = channelForm.getFieldsValue();
|
||||
if (!channel?.baseUrl) {
|
||||
message.warning("请先填写接口地址");
|
||||
return;
|
||||
}
|
||||
if (editingChannelIndex === null && !channel?.apiKey) {
|
||||
message.warning("请先填写 API Key");
|
||||
return;
|
||||
}
|
||||
setIsFetchingChannelModels(true);
|
||||
try {
|
||||
const channelModels = await fetchChannelModels(token, { index: editingChannelIndex ?? undefined, channel: normalizeChannel(channel) });
|
||||
const current = isModelSelectorOpen ? uniqueModels(modelSelectSelected) : uniqueModels(channelForm.getFieldValue("models") || []);
|
||||
rememberModels(channelModels);
|
||||
if (!channelModels.length) {
|
||||
message.warning("上游未返回模型列表,请手动输入模型名称");
|
||||
return;
|
||||
}
|
||||
setModelSelectExisting(current);
|
||||
setModelSelectSource(uniqueModels(channelModels));
|
||||
setModelSelectSelected(uniqueModels([...current, ...channelModels]));
|
||||
setModelSelectKeyword("");
|
||||
setModelSelectNewModel("");
|
||||
setModelSelectTab("new");
|
||||
setIsModelSelectorOpen(true);
|
||||
message.success(`已获取 ${channelModels.length} 个模型,请选择后确认`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setIsFetchingChannelModels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openChannelModelSelector = (sourceModels?: string[]) => {
|
||||
const current = uniqueModels(channelForm.getFieldValue("models") || []);
|
||||
const source = uniqueModels(sourceModels !== undefined ? sourceModels : [...knownModels, ...current]);
|
||||
setModelSelectExisting(current);
|
||||
setModelSelectSource(source);
|
||||
setModelSelectSelected(sourceModels ? uniqueModels([...current, ...source]) : current);
|
||||
setModelSelectKeyword("");
|
||||
setModelSelectNewModel("");
|
||||
setModelSelectTab(sourceModels ? "new" : "current");
|
||||
setIsModelSelectorOpen(true);
|
||||
};
|
||||
|
||||
const closeChannelModelSelector = () => {
|
||||
setIsModelSelectorOpen(false);
|
||||
setModelSelectKeyword("");
|
||||
setModelSelectNewModel("");
|
||||
};
|
||||
|
||||
const confirmChannelModelSelector = () => {
|
||||
const models = uniqueModels(modelSelectSelected);
|
||||
channelForm.setFieldValue("models", models);
|
||||
rememberModels(models);
|
||||
closeChannelModelSelector();
|
||||
};
|
||||
|
||||
const toggleSelectedModel = (model: string, checked: boolean) => {
|
||||
setModelSelectSelected((current) => (checked ? uniqueModels([...current, model]) : current.filter((item) => item !== model)));
|
||||
};
|
||||
|
||||
const selectActiveModels = () => {
|
||||
setModelSelectSelected((current) => uniqueModels([...current, ...activeModelSelectModels]));
|
||||
};
|
||||
|
||||
const clearActiveModels = () => {
|
||||
const active = new Set(activeModelSelectModels);
|
||||
setModelSelectSelected((current) => current.filter((model) => !active.has(model)));
|
||||
};
|
||||
|
||||
const addModelInSelector = () => {
|
||||
const model = modelSelectNewModel.trim();
|
||||
if (!model) return;
|
||||
setModelSelectExisting((current) => uniqueModels([...current, model]));
|
||||
setModelSelectSelected((current) => uniqueModels([...current, model]));
|
||||
setModelSelectNewModel("");
|
||||
setModelSelectTab("current");
|
||||
};
|
||||
|
||||
function rememberModels(models: string[]) {
|
||||
setKnownModels((current) => uniqueModels([...current, ...models]));
|
||||
}
|
||||
|
||||
function rememberKnownModels(settings: AdminSettings) {
|
||||
rememberModels(collectKnownModels(settings));
|
||||
}
|
||||
|
||||
const openTestDialog = (index: number) => {
|
||||
const channel = normalizeChannel(channels[index]);
|
||||
if (!channel.baseUrl || channel.models.length === 0) {
|
||||
message.warning("请先填写接口地址和至少一个模型");
|
||||
return;
|
||||
}
|
||||
setTestChannelIndex(index);
|
||||
setTestKeyword("");
|
||||
setSelectedTestModels([]);
|
||||
setTestingModels([]);
|
||||
setTestResults({});
|
||||
};
|
||||
|
||||
const closeTestDialog = () => {
|
||||
setTestChannelIndex(null);
|
||||
setTestKeyword("");
|
||||
setSelectedTestModels([]);
|
||||
setTestingModels([]);
|
||||
setTestResults({});
|
||||
};
|
||||
|
||||
const testModelOnline = async (model: string) => {
|
||||
if (testChannelIndex === null) return;
|
||||
if (!token) return;
|
||||
const channel = normalizeChannel(channels[testChannelIndex]);
|
||||
setTestingModels((current) => [...current, model]);
|
||||
try {
|
||||
const startedAt = performance.now();
|
||||
const result = await testChannelModel(token, { index: testChannelIndex, channel, model });
|
||||
setTestResults((current) => ({ ...current, [model]: { status: "success", duration: `${((performance.now() - startedAt) / 1000).toFixed(2)}s`, message: result } }));
|
||||
} catch (error) {
|
||||
setTestResults((current) => ({ ...current, [model]: { status: "error", message: error instanceof Error ? error.message : "测试失败" } }));
|
||||
} finally {
|
||||
setTestingModels((current) => current.filter((item) => item !== model));
|
||||
}
|
||||
};
|
||||
|
||||
const batchTestModels = async () => {
|
||||
for (const model of selectedTestModels) {
|
||||
await testModelOnline(model);
|
||||
}
|
||||
};
|
||||
|
||||
const testChannel = testChannelIndex === null ? null : normalizeChannel(channels[testChannelIndex]);
|
||||
const testModels = (testChannel?.models || []).filter((model) => model.toLowerCase().includes(testKeyword.trim().toLowerCase()));
|
||||
|
||||
async function persistChannels(nextChannels: AdminModelChannel[]) {
|
||||
if (!token) return;
|
||||
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
||||
const nextChannelModels = collectChannelModels(nextChannels);
|
||||
const nextSettings = normalizeSettings({
|
||||
...values,
|
||||
public: { ...values.public, modelChannel: { ...values.public.modelChannel, availableModels: nextChannelModels } },
|
||||
private: { ...values.private, channels: nextChannels },
|
||||
});
|
||||
const saved = normalizeSettings(await saveAdminSettings(token, nextSettings));
|
||||
const merged = mergeChannelApiKeys(nextChannels, saved);
|
||||
setChannels(merged.private.channels);
|
||||
setModelCosts(merged.public.modelChannel.modelCosts);
|
||||
rememberKnownModels(merged);
|
||||
form.setFieldsValue(merged);
|
||||
setJsonText({
|
||||
public: JSON.stringify(merged.public, null, 2),
|
||||
private: JSON.stringify(merged.private, null, 2),
|
||||
});
|
||||
message.success("已保存");
|
||||
}
|
||||
|
||||
return (
|
||||
<main style={{ padding: 24 }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Card variant="borderless">
|
||||
<Flex justify="space-between" align="center" gap={16} wrap>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => changeTab(key as SettingsTabKey)}
|
||||
items={[
|
||||
{ key: "public", label: "公开配置(对外暴露)" },
|
||||
{ key: "private", label: "私有配置(不会对外暴露)" },
|
||||
]}
|
||||
/>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} loading={isLoading} onClick={() => void loadSettings()}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={isSaving} onClick={() => void saveSettings()}>
|
||||
保存设置
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless">
|
||||
<Flex justify="space-between" align="center" gap={16} wrap style={{ marginBottom: 16 }}>
|
||||
<Segmented
|
||||
value={activeMode}
|
||||
onChange={(value) => toggleMode(activeTab, value as EditorMode)}
|
||||
options={[
|
||||
{ label: "可视化编辑", value: "visual" },
|
||||
{ label: "手动编辑 JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
{activeMode === "json" ? (
|
||||
<Space>
|
||||
{jsonError ? (
|
||||
<Tag color="error">{jsonError}</Tag>
|
||||
) : (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>
|
||||
JSON 格式正确
|
||||
</Tag>
|
||||
)}
|
||||
<Button icon={<FormatPainterOutlined />} onClick={() => formatJson(activeTab)}>
|
||||
格式化
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{activeTab === "public" ? "这些配置会暴露给前端读取" : "这些配置只会在后台保存"}</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{activeTab === "public" ? (
|
||||
activeMode === "visual" ? (
|
||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "modelChannel", "availableModels"]} label="系统可用模型(请先在私有配置里配置渠道)" extra="保存设置时会自动合并所有已启用私有渠道的模型,前台模型下拉会读取这里的公开列表">
|
||||
<Select mode="multiple" placeholder="请选择系统可用模型" options={channelModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultModel"]} label="默认模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultImageModel"]} label="默认图片模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultVideoModel"]} label="默认视频模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "modelChannel", "defaultTextModel"]} label="默认文本模型">
|
||||
<Select showSearch allowClear options={publicModels.map((item) => ({ label: item, value: item }))} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "modelChannel", "systemPrompt"]} label="系统提示词">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "modelChannel", "allowCustomChannel"]} label="是否允许用户自定义渠道" extra="开启后,前端可提供走后端渠道和用户自定义 baseUrl 直连两种模式" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name={["public", "auth", "allowRegister"]} label="是否允许用户注册" extra="关闭后隐藏注册入口,注册接口也会拒绝新用户创建" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Typography.Title level={5}>模型算力点</Typography.Title>
|
||||
<Table
|
||||
rowKey="model"
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={publicModels.map((model) => ({ model, credits: modelCostCredits(modelCosts, model) }))}
|
||||
columns={[
|
||||
{ title: "模型", dataIndex: "model" },
|
||||
{
|
||||
title: "每次调用扣除",
|
||||
dataIndex: "credits",
|
||||
width: 220,
|
||||
render: (_, item) => (
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1}
|
||||
precision={0}
|
||||
className="!w-full"
|
||||
value={item.credits}
|
||||
addonAfter="点"
|
||||
onChange={(value) => setModelCost(form, setModelCosts, item.model, Number(value) || 0)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
) : (
|
||||
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
|
||||
<CodeMirror
|
||||
value={activeJsonText}
|
||||
height="520px"
|
||||
extensions={[json(), jsonEditorTheme]}
|
||||
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
|
||||
theme="none"
|
||||
onChange={(value) => setJsonText((current) => ({ ...current, public: value }))}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : activeMode === "visual" ? (
|
||||
<Form form={form} layout="vertical" initialValues={emptySettings} requiredMark={false}>
|
||||
<Flex vertical gap={12}>
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<img src="/icons/linuxdo.svg" alt="" width={18} height={18} />
|
||||
Linux.do 登录
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Flex vertical gap={14}>
|
||||
<Typography.Text type="secondary">
|
||||
本项目接口回调地址是 /api/auth/linux-do/callback,请在 Linux.do 应用后台自行拼接站点前缀。
|
||||
<Typography.Link href="https://connect.linux.do" target="_blank" rel="noreferrer">
|
||||
点击此处管理你的 LinuxDO OAuth App
|
||||
</Typography.Link>
|
||||
</Typography.Text>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={6}>
|
||||
<Form.Item name={["public", "auth", "linuxDo", "enabled"]} label="开启 Linux.do 登录" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={9}>
|
||||
<Form.Item name={["private", "auth", "linuxDo", "clientId"]} label="Linux.do Client ID">
|
||||
<Input placeholder="输入 Linux.do OAuth App 的 ID" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={9}>
|
||||
<Form.Item name={["private", "auth", "linuxDo", "clientSecret"]} label="Linux.do Client Secret">
|
||||
<Input.Password placeholder="留空则沿用已保存的密钥" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Flex>
|
||||
</Card>
|
||||
<Card size="small" title="提示词定时同步">
|
||||
<Row gutter={16} align="middle">
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item name={["private", "promptSync", "enabled"]} label="开启定时同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={16}>
|
||||
<Form.Item name={["private", "promptSync", "cron"]} label="Cron 表达式" extra="默认每 5 分钟同步内置 GitHub 远程提示词源">
|
||||
<Input placeholder="*/5 * * * *" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openChannelDrawer(null)}>
|
||||
新增渠道
|
||||
</Button>
|
||||
<Table
|
||||
rowKey="_rowKey"
|
||||
pagination={false}
|
||||
dataSource={channelTableData}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name", render: (value) => value || "未命名渠道" },
|
||||
{ title: "协议", dataIndex: "protocol", width: 96, render: (value) => <Tag>{value || "openai"}</Tag> },
|
||||
{ title: "状态", dataIndex: "enabled", width: 96, render: (value) => <Tag color={value ? "success" : "default"}>{value ? "已启用" : "已停用"}</Tag> },
|
||||
{
|
||||
title: "模型",
|
||||
dataIndex: "models",
|
||||
render: (value: string[]) => (
|
||||
<Typography.Text ellipsis style={{ maxWidth: 360 }}>
|
||||
{modelSummary(value || [])}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{ title: "权重", dataIndex: "weight", width: 88 },
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 220,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" onClick={() => openTestDialog(item._index)}>
|
||||
测试
|
||||
</Button>
|
||||
<Button size="small" onClick={() => openChannelDrawer(item._index)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
const nextChannels = [...channels];
|
||||
nextChannels.splice(item._index, 1);
|
||||
void persistChannels(nextChannels);
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</Form>
|
||||
) : (
|
||||
<div style={{ overflow: "hidden", border: "1px solid var(--ant-color-border)", borderRadius: 6 }}>
|
||||
<CodeMirror
|
||||
value={activeJsonText}
|
||||
height="520px"
|
||||
extensions={[json(), jsonEditorTheme]}
|
||||
basicSetup={{ foldGutter: true, lineNumbers: true, highlightActiveLine: true, highlightActiveLineGutter: true }}
|
||||
theme="none"
|
||||
onChange={(value) => setJsonText((current) => ({ ...current, private: value }))}
|
||||
style={{ fontSize: 13 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Drawer
|
||||
title={editingChannelIndex === null ? "新增渠道" : "编辑渠道"}
|
||||
open={isChannelDrawerOpen}
|
||||
size={560}
|
||||
onClose={closeChannelDrawer}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={closeChannelDrawer}>取消</Button>
|
||||
<Button type="primary" onClick={() => void saveChannel()}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={channelForm} layout="vertical" requiredMark={false} initialValues={emptyChannel}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请输入渠道名称" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="protocol" label="协议">
|
||||
<Select options={[{ label: "OpenAI", value: "openai" }]} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="weight" label="权重">
|
||||
<InputNumber min={1} step={1} className="!w-full" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="baseUrl" label="接口地址" rules={[{ required: true, message: "请输入接口地址" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="apiKey" label="API Key" rules={editingChannelIndex === null ? [{ required: true, message: "请输入 API Key" }] : []}>
|
||||
<Input.Password placeholder={editingChannelIndex === null ? "" : "留空则沿用已保存的 API Key"} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="渠道可用模型">
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="models" noStyle>
|
||||
<Select mode="tags" maxTagCount="responsive" tokenSeparators={[",", "\n"]} options={knownModels.map((model) => ({ label: model, value: model }))} />
|
||||
</Form.Item>
|
||||
<Button onClick={() => openChannelModelSelector()}>选择模型</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Modal
|
||||
title={
|
||||
<Space size={12}>
|
||||
选择渠道模型
|
||||
<Typography.Text type="secondary">
|
||||
已选择 {modelSelectSelected.length} / {uniqueModels([...modelSelectSource, ...modelSelectExisting]).length}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
open={isModelSelectorOpen}
|
||||
width={960}
|
||||
onCancel={closeChannelModelSelector}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={closeChannelModelSelector}>取消</Button>
|
||||
<Button type="primary" onClick={confirmChannelModelSelector}>
|
||||
确定
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Flex vertical gap={14}>
|
||||
<Flex gap={12} wrap>
|
||||
<Input.Search placeholder="搜索模型" allowClear value={modelSelectKeyword} onChange={(event) => setModelSelectKeyword(event.target.value)} style={{ flex: "1 1 260px" }} />
|
||||
<Space.Compact style={{ flex: "1 1 320px" }}>
|
||||
<Input value={modelSelectNewModel} placeholder="输入模型名称" onChange={(event) => setModelSelectNewModel(event.target.value)} onPressEnter={addModelInSelector} />
|
||||
<Button onClick={addModelInSelector}>增加模型</Button>
|
||||
<Button icon={<ReloadOutlined />} loading={isFetchingChannelModels} onClick={() => void fetchChannelModelList()}>
|
||||
拉取模型列表
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Flex>
|
||||
<Typography.Text type="secondary">如果上游不提供 OpenAI /models 模型列表接口,请在这里手动增加模型名称。</Typography.Text>
|
||||
<Tabs
|
||||
activeKey={modelSelectTab}
|
||||
onChange={(key) => setModelSelectTab(key as ModelSelectTabKey)}
|
||||
items={[
|
||||
{ key: "new", label: `新获取的模型 (${modelSelectGroups.new.length})` },
|
||||
{ key: "current", label: `已有的模型 (${modelSelectGroups.current.length})` },
|
||||
]}
|
||||
/>
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<Typography.Text type="secondary">
|
||||
当前列表已选择 {activeSelectedCount} / {activeModelSelectModels.length}
|
||||
</Typography.Text>
|
||||
<Space size={8}>
|
||||
<Button size="small" disabled={!activeModelSelectModels.length || activeSelectedCount === activeModelSelectModels.length} onClick={selectActiveModels}>
|
||||
全选当前列表
|
||||
</Button>
|
||||
<Button size="small" disabled={!activeSelectedCount} onClick={clearActiveModels}>
|
||||
取消当前列表
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
<div style={{ maxHeight: 420, overflowY: "auto", borderTop: "1px solid var(--ant-color-border-secondary)", paddingTop: 12 }}>
|
||||
{activeModelSelectModels.length ? (
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(2, minmax(0, 1fr))", columnGap: 24, rowGap: 12 }}>
|
||||
{activeModelSelectModels.map((model) => (
|
||||
<Checkbox key={model} checked={modelSelectSelected.includes(model)} onChange={(event) => toggleSelectedModel(model, event.target.checked)}>
|
||||
<Typography.Text style={{ wordBreak: "break-all" }}>{model}</Typography.Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: "48px 0", textAlign: "center" }}>
|
||||
<Typography.Text type="secondary">没有匹配的模型</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
{testChannel?.name || "渠道"} 渠道的模型测试<Typography.Text type="secondary">共 {testChannel?.models.length || 0} 个模型</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
open={testChannelIndex !== null}
|
||||
width={920}
|
||||
onCancel={closeTestDialog}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={closeTestDialog}>取消</Button>
|
||||
<Button type="primary" disabled={!selectedTestModels.length || testingModels.length > 0} onClick={() => void batchTestModels()}>
|
||||
批量测试 {selectedTestModels.length} 个模型
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
<Typography.Text type="secondary">普通文本模型会发送一条 hi;Agent Plan / Seedance 视频模型只做配置格式检查,不会发起视频生成,也不代表模型权限已验证。</Typography.Text>
|
||||
<Input.Search placeholder="搜索模型..." allowClear value={testKeyword} onChange={(event) => setTestKeyword(event.target.value)} />
|
||||
<Table
|
||||
rowKey="model"
|
||||
pagination={false}
|
||||
scroll={{ y: 420 }}
|
||||
dataSource={testModels.map((model) => ({ model }))}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedTestModels,
|
||||
onChange: (keys) => setSelectedTestModels(keys.map(String)),
|
||||
}}
|
||||
columns={[
|
||||
{ title: "模型名称", dataIndex: "model", render: (value) => <Typography.Text strong>{value}</Typography.Text> },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "model",
|
||||
width: 260,
|
||||
render: (value) => {
|
||||
if (testingModels.includes(value)) return <Tag icon={<LoadingOutlined className="animate-spin" />}>测试中</Tag>;
|
||||
const result = testResults[value];
|
||||
if (!result) return <Tag>未开始</Tag>;
|
||||
return result.status === "success" ? (
|
||||
<Space size={6} wrap>
|
||||
<Tag color="success">成功</Tag>
|
||||
<Typography.Text type="secondary">请求时长: {result.duration}</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<Typography.Text type="danger">{result.message}</Typography.Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 120,
|
||||
align: "right",
|
||||
render: (_, item) => (
|
||||
<Button size="small" loading={testingModels.includes(item.model)} onClick={() => void testModelOnline(item.model)}>
|
||||
测试
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</Modal>
|
||||
</Flex>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSettings(settings: Partial<AdminSettings> = {}): AdminSettings {
|
||||
const privateSetting = normalizePrivateSetting(settings.private);
|
||||
return {
|
||||
public: {
|
||||
...normalizePublicSetting(settings.public),
|
||||
},
|
||||
private: privateSetting,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePublicSetting(setting: Partial<AdminSettings["public"]> = {}): AdminSettings["public"] {
|
||||
return {
|
||||
...emptySettings.public,
|
||||
modelChannel: {
|
||||
...emptySettings.public.modelChannel,
|
||||
...(setting.modelChannel || {}),
|
||||
availableModels: setting.modelChannel?.availableModels || [],
|
||||
modelCosts: normalizeModelCosts(setting.modelChannel?.modelCosts || []),
|
||||
},
|
||||
auth: {
|
||||
allowRegister: setting.auth?.allowRegister !== false,
|
||||
linuxDo: {
|
||||
enabled: setting.auth?.linuxDo?.enabled === true,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeModelCosts(items: Partial<AdminSettings["public"]["modelChannel"]["modelCosts"][number]>[]) {
|
||||
return items.filter((item) => item.model).map((item) => ({ model: item.model || "", credits: Math.max(0, Number(item.credits) || 0) }));
|
||||
}
|
||||
|
||||
function normalizePrivateSetting(setting: Partial<AdminSettings["private"]> = {}): AdminSettings["private"] {
|
||||
return {
|
||||
channels: (setting.channels || []).map(normalizeChannel),
|
||||
promptSync: {
|
||||
enabled: setting.promptSync?.enabled !== false,
|
||||
cron: setting.promptSync?.cron || "*/5 * * * *",
|
||||
},
|
||||
auth: {
|
||||
linuxDo: {
|
||||
clientId: setting.auth?.linuxDo?.clientId || "",
|
||||
clientSecret: setting.auth?.linuxDo?.clientSecret || "",
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChannel(item: Partial<AdminModelChannel> = {}): AdminModelChannel {
|
||||
return {
|
||||
protocol: "openai",
|
||||
name: item.name || "",
|
||||
baseUrl: item.baseUrl || "",
|
||||
apiKey: item.apiKey || "",
|
||||
models: item.models || [],
|
||||
weight: Math.max(1, Number(item.weight) || 1),
|
||||
enabled: item.enabled !== false,
|
||||
remark: item.remark || "",
|
||||
};
|
||||
}
|
||||
|
||||
function modelCostCredits(items: AdminSettings["public"]["modelChannel"]["modelCosts"], model: string) {
|
||||
return items.find((item) => item.model === model)?.credits || 0;
|
||||
}
|
||||
|
||||
function setModelCost(form: any, setModelCosts: (items: AdminModelCost[]) => void, model: string, credits: number) {
|
||||
const current = (form.getFieldValue(["public", "modelChannel", "modelCosts"]) || []) as AdminSettings["public"]["modelChannel"]["modelCosts"];
|
||||
const next = current.filter((item) => item.model !== model);
|
||||
next.push({ model, credits: Math.max(0, credits) });
|
||||
form.setFieldValue(["public", "modelChannel", "modelCosts"], next);
|
||||
setModelCosts(next);
|
||||
}
|
||||
|
||||
function mergeChannelApiKeys(currentChannels: AdminModelChannel[], saved: AdminSettings): AdminSettings {
|
||||
const channels = saved.private.channels.map((item, index) => ({
|
||||
...item,
|
||||
apiKey: currentChannels[index]?.apiKey || item.apiKey,
|
||||
}));
|
||||
return {
|
||||
public: saved.public,
|
||||
private: { ...saved.private, channels },
|
||||
};
|
||||
}
|
||||
|
||||
function collectChannelModels(channels: AdminModelChannel[]) {
|
||||
return uniqueModels(channels.filter((channel) => channel.enabled).flatMap((channel) => channel.models || []));
|
||||
}
|
||||
|
||||
function collectKnownModels(settings: AdminSettings) {
|
||||
return uniqueModels([
|
||||
...(settings.public.modelChannel.availableModels || []),
|
||||
...(settings.public.modelChannel.modelCosts || []).map((item) => item.model),
|
||||
...settings.private.channels.flatMap((channel) => channel.models || []),
|
||||
]);
|
||||
}
|
||||
|
||||
function buildModelSelectGroups(sourceModels: string[], existingModels: string[]): Record<ModelSelectTabKey, string[]> {
|
||||
const source = uniqueModels(sourceModels);
|
||||
const existing = uniqueModels(existingModels);
|
||||
const existingSet = new Set(existing);
|
||||
return {
|
||||
new: source.filter((model) => !existingSet.has(model)),
|
||||
current: existing,
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.filter(Boolean)));
|
||||
}
|
||||
|
||||
function modelSummary(models: string[]) {
|
||||
if (!models.length) return "未配置模型";
|
||||
const preview = models.slice(0, 3).join(", ");
|
||||
return models.length > 3 ? `${models.length} 个模型:${preview}...` : preview;
|
||||
}
|
||||
|
||||
function parseTabJson(tab: "public", value: string): AdminSettings["public"] | null;
|
||||
function parseTabJson(tab: "private", value: string): AdminSettings["private"] | null;
|
||||
function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null;
|
||||
function parseTabJson(tab: SettingsTabKey, value: string): AdminSettings[SettingsTabKey] | null {
|
||||
try {
|
||||
return tab === "public" ? normalizePublicSetting(JSON.parse(value) as Partial<AdminSettings["public"]>) : normalizePrivateSetting(JSON.parse(value) as Partial<AdminSettings["private"]>);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectSettings(form: any, editorMode: Record<SettingsTabKey, EditorMode>, jsonText: Record<SettingsTabKey, string>, message: { error: (value: string) => void }) {
|
||||
const values = normalizeSettings(form.getFieldsValue(true) as AdminSettings);
|
||||
if (editorMode.public === "json") {
|
||||
const publicSetting = parseTabJson("public", jsonText.public);
|
||||
if (!publicSetting) {
|
||||
message.error("公开配置 JSON 格式不正确");
|
||||
return null;
|
||||
}
|
||||
values.public = publicSetting;
|
||||
}
|
||||
if (editorMode.private === "json") {
|
||||
const privateSetting = parseTabJson("private", jsonText.private);
|
||||
if (!privateSetting) {
|
||||
message.error("私有配置 JSON 格式不正确");
|
||||
return null;
|
||||
}
|
||||
values.private = privateSetting;
|
||||
}
|
||||
values.public.modelChannel.availableModels = collectChannelModels(values.private.channels);
|
||||
return normalizeSettings(values);
|
||||
}
|
||||
|
||||
function getJsonError(value: string) {
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return "";
|
||||
} catch (error) {
|
||||
return error instanceof Error ? error.message : "JSON 格式不正确";
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { ProTable, type ProColumns } from "@ant-design/pro-components";
|
||||
import { Avatar, Button, Card, Col, Divider, Flex, Form, Input, InputNumber, Modal, Row, Select, Space, Tag, Tooltip, Typography } from "antd";
|
||||
import dayjs from "dayjs";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import type { AdminUser } from "@/services/api/admin";
|
||||
import { useAdminUsers } from "./use-admin-users";
|
||||
|
||||
type UserFormValues = Partial<AdminUser> & { password?: string };
|
||||
|
||||
const roleOptions = [
|
||||
{ label: "普通用户", value: "user" },
|
||||
{ label: "管理员", value: "admin" },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ label: "正常", value: "active" },
|
||||
{ label: "禁用", value: "ban" },
|
||||
];
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { users, keyword, page, pageSize, total, isLoading, searchUsers, changePage, changePageSize, resetFilters, refreshUsers, saveUser: saveAdminUser, adjustCredits, deleteUser } = useAdminUsers();
|
||||
const [form] = Form.useForm<UserFormValues>();
|
||||
const [keywordText, setKeywordText] = useState(keyword);
|
||||
const [editingUser, setEditingUser] = useState<Partial<AdminUser> | null>(null);
|
||||
const [deletingUser, setDeletingUser] = useState<AdminUser | null>(null);
|
||||
|
||||
useEffect(() => setKeywordText(keyword), [keyword]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingUser) form.setFieldsValue({ role: "user", status: "active", ...editingUser, password: "" });
|
||||
}, [editingUser, form]);
|
||||
|
||||
const saveUser = async () => {
|
||||
const value = await form.validateFields();
|
||||
const userValue = { ...value };
|
||||
delete userValue.credits;
|
||||
await saveAdminUser({ ...editingUser, ...userValue, password: value.password || undefined });
|
||||
setEditingUser(null);
|
||||
};
|
||||
|
||||
const saveCredits = async () => {
|
||||
if (!editingUser?.id) return;
|
||||
await adjustCredits(editingUser.id, form.getFieldValue("credits") || 0);
|
||||
};
|
||||
|
||||
const columns: ProColumns<AdminUser>[] = [
|
||||
{
|
||||
title: "用户",
|
||||
dataIndex: "username",
|
||||
width: 260,
|
||||
render: (_, item) => (
|
||||
<Flex align="center" gap={10} style={{ minWidth: 0 }}>
|
||||
<Avatar src={item.avatarUrl || undefined}>{(item.displayName || item.username || "U").slice(0, 1).toUpperCase()}</Avatar>
|
||||
<Flex vertical style={{ minWidth: 0 }}>
|
||||
<Typography.Text strong ellipsis>
|
||||
{item.displayName || item.username}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" ellipsis>
|
||||
{item.username}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "角色",
|
||||
dataIndex: "role",
|
||||
width: 100,
|
||||
render: (_, item) => <Tag color={item.role === "admin" ? "gold" : "default"}>{item.role === "admin" ? "管理员" : "用户"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 90,
|
||||
render: (_, item) => <Tag color={item.status === "ban" ? "red" : "green"}>{item.status === "ban" ? "禁用" : "正常"}</Tag>,
|
||||
},
|
||||
{
|
||||
title: "算力点",
|
||||
dataIndex: "credits",
|
||||
width: 100,
|
||||
render: (_, item) => <Typography.Text>{item.credits}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "Linux.do",
|
||||
dataIndex: "linuxDoId",
|
||||
width: 140,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.linuxDoId || "-"}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: "最近登录",
|
||||
dataIndex: "lastLoginAt",
|
||||
width: 180,
|
||||
render: (_, item) => <Typography.Text type="secondary">{item.lastLoginAt ? dayjs(item.lastLoginAt).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={() => setEditingUser(item)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="删除">
|
||||
<Button danger type="text" size="small" icon={<DeleteOutlined />} onClick={() => setDeletingUser(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="搜索用户名、昵称、邮箱或 Linux.do ID"
|
||||
allowClear
|
||||
enterButton={<SearchOutlined />}
|
||||
onSearch={() => searchUsers(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={() => searchUsers(keywordText)}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
<ProTable<AdminUser>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
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 refreshUsers() }}
|
||||
toolBarRender={() => [
|
||||
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => setEditingUser({ role: "user", status: "active" })}>
|
||||
新增
|
||||
</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={editingUser?.id ? "编辑用户" : "新增用户"} open={Boolean(editingUser)} width={680} onCancel={() => setEditingUser(null)} onOk={() => void saveUser()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Typography.Text strong>基础信息</Typography.Text>
|
||||
<Row gutter={14}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: "请输入用户名" }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="password" label={editingUser?.id ? "新密码" : "密码"} rules={editingUser?.id ? [] : [{ required: true, message: "请输入密码" }]}>
|
||||
<Input.Password autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="displayName" label="昵称">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true, message: "请选择角色" }]}>
|
||||
<Select options={roleOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true, message: "请选择状态" }]}>
|
||||
<Select options={statusOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
{editingUser?.id ? (
|
||||
<>
|
||||
<Divider style={{ margin: "4px 0 16px" }} />
|
||||
<Typography.Text strong>算力点调整</Typography.Text>
|
||||
<Row gutter={14}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="算力点">
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="credits" noStyle>
|
||||
<InputNumber min={0} precision={0} style={{ width: "100%" }} />
|
||||
</Form.Item>
|
||||
<Button onClick={() => void saveCredits()}>调整</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
) : null}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="删除用户"
|
||||
open={Boolean(deletingUser)}
|
||||
onCancel={() => setDeletingUser(null)}
|
||||
onOk={async () => {
|
||||
if (!deletingUser) return;
|
||||
await deleteUser(deletingUser.id);
|
||||
setDeletingUser(null);
|
||||
}}
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="取消"
|
||||
>
|
||||
确定删除「{deletingUser?.displayName || deletingUser?.username}」吗?删除后该账号将无法继续登录。
|
||||
</Modal>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { App } from "antd";
|
||||
|
||||
import { adjustAdminUserCredits, deleteAdminUser, fetchAdminUsers, saveAdminUser, type AdminUser } from "@/services/api/admin";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
const defaultPageSize = 10;
|
||||
|
||||
export function useAdminUsers() {
|
||||
const { message } = App.useApp();
|
||||
const queryClient = useQueryClient();
|
||||
const token = useUserStore((state) => state.token);
|
||||
const clearSession = useUserStore((state) => state.clearSession);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["admin", "users", token, keyword, page, pageSize],
|
||||
queryFn: () => fetchAdminUsers(token, { keyword, page, pageSize }),
|
||||
enabled: Boolean(token),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (user: Partial<AdminUser> & { password?: string }) => saveAdminUser(token, user),
|
||||
onSuccess: async (_, user) => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
message.success(user.id ? "用户已保存" : "用户已新增");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "保存失败"),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => deleteAdminUser(token, id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
message.success("用户已删除");
|
||||
},
|
||||
onError: (error) => message.error(error instanceof Error ? error.message : "删除失败"),
|
||||
});
|
||||
|
||||
const creditMutation = useMutation({
|
||||
mutationFn: ({ id, credits }: { id: string; credits: number }) => adjustAdminUserCredits(token, id, credits),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["admin", "users"] });
|
||||
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; page: number; pageSize: number }>) => {
|
||||
const queryState = { keyword, page, pageSize, ...next };
|
||||
if (next.keyword !== undefined || next.pageSize !== undefined) queryState.page = 1;
|
||||
setKeyword(queryState.keyword);
|
||||
setPage(queryState.page);
|
||||
setPageSize(queryState.pageSize);
|
||||
};
|
||||
|
||||
const data = query.data;
|
||||
|
||||
return {
|
||||
users: data?.items || [],
|
||||
keyword,
|
||||
page,
|
||||
pageSize,
|
||||
total: data?.total || 0,
|
||||
isLoading: query.isFetching || saveMutation.isPending || deleteMutation.isPending || creditMutation.isPending,
|
||||
searchUsers: (value = keyword) => updateFilters({ keyword: value }),
|
||||
changePage: (value: number) => updateFilters({ page: value }),
|
||||
changePageSize: (value: number) => updateFilters({ pageSize: value }),
|
||||
resetFilters: () => updateFilters({ keyword: "", page: 1, pageSize: defaultPageSize }),
|
||||
refreshUsers: () => query.refetch(),
|
||||
saveUser: (user: Partial<AdminUser> & { password?: string }) => saveMutation.mutateAsync(user),
|
||||
adjustCredits: (id: string, credits: number) => creditMutation.mutateAsync({ id, credits }),
|
||||
deleteUser: (id: string) => deleteMutation.mutateAsync(id),
|
||||
};
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Copy, FolderPlus, Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Button, Card, Drawer, Empty, Image, Input, Pagination, Spin, Tag, Typography } from "antd";
|
||||
import axios from "axios";
|
||||
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
|
||||
import { uploadImage } from "@/services/image-storage";
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
|
||||
export default function AssetLibraryPage() {
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [selectedType, setSelectedType] = useState("");
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selectedAsset, setSelectedAsset] = useState<AssetLibraryItem | null>(null);
|
||||
const addAsset = useAssetStore((state) => state.addAsset);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["asset-library", keyword, selectedType, selectedTags, page],
|
||||
queryFn: () => fetchAssetLibrary({ keyword, type: selectedType, tag: selectedTags, page, pageSize: PAGE_SIZE }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (query.isError) {
|
||||
message.error(query.error instanceof Error ? query.error.message : "获取素材库失败");
|
||||
}
|
||||
}, [message, query.error, query.isError]);
|
||||
|
||||
const isReady = query.isFetched || query.isError;
|
||||
const items = query.data?.items || [];
|
||||
const availableTags = query.data?.tags || [];
|
||||
const total = query.data?.total || 0;
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
setSelectedTags((items) => (items.includes(tag) ? items.filter((item) => item !== tag) : [...items, tag]));
|
||||
};
|
||||
|
||||
const saveToMyAssets = async (asset: AssetLibraryItem) => {
|
||||
try {
|
||||
if (asset.type === "image") {
|
||||
const dataUrl = await remoteImageToDataUrl(asset.url);
|
||||
const image = await uploadImage(dataUrl);
|
||||
addAsset({
|
||||
kind: "image",
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags,
|
||||
source: asset.category,
|
||||
note: asset.description,
|
||||
data: { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType },
|
||||
metadata: { source: "asset-library", assetId: asset.id },
|
||||
});
|
||||
} else {
|
||||
addAsset({
|
||||
kind: "text",
|
||||
title: asset.title,
|
||||
coverUrl: asset.coverUrl,
|
||||
tags: asset.tags,
|
||||
source: asset.category,
|
||||
note: asset.description,
|
||||
data: { content: asset.content },
|
||||
metadata: { source: "asset-library", assetId: asset.id },
|
||||
});
|
||||
}
|
||||
message.success("已加入我的素材");
|
||||
} catch {
|
||||
message.error("加入失败");
|
||||
}
|
||||
};
|
||||
|
||||
if (!isReady) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-stone-800 dark:text-stone-100">
|
||||
<main className="min-h-0 flex-1 overflow-y-auto bg-background bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] px-6 py-8 [background-size:16px_16px] dark:bg-[radial-gradient(rgba(245,245,244,.16)_1px,transparent_1px)]">
|
||||
<div className="pb-8">
|
||||
<div className="mx-auto max-w-5xl text-center">
|
||||
<h1 className="text-4xl font-semibold tracking-tight text-stone-950 dark:text-stone-100">素材库</h1>
|
||||
<p className="mt-3 text-sm text-stone-500 dark:text-stone-400">挑选团队素材,加入我的素材后继续编辑和使用。</p>
|
||||
</div>
|
||||
<div className="mx-auto mt-8 w-full max-w-2xl">
|
||||
<Input
|
||||
size="large"
|
||||
className="w-full"
|
||||
prefix={<Search className="size-4 text-stone-400" />}
|
||||
value={keyword}
|
||||
placeholder="按标题查询"
|
||||
onChange={(event) => {
|
||||
setPage(1);
|
||||
setKeyword(event.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mx-auto mt-6 max-w-6xl space-y-3">
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">类型</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
].map((item) => (
|
||||
<Tag.CheckableTag
|
||||
key={item.value || "all"}
|
||||
checked={selectedType === item.value}
|
||||
className={cn("prompt-filter-tag", selectedType === item.value && "is-active")}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setSelectedType(item.value);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[56px_minmax(0,1fr)] sm:items-start">
|
||||
<div className="pt-2 text-xs font-medium text-stone-500 dark:text-stone-400">标签</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tag.CheckableTag
|
||||
checked={selectedTags.length === 0}
|
||||
className={cn("prompt-filter-tag", selectedTags.length === 0 && "is-active")}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setSelectedTags([]);
|
||||
}}
|
||||
>
|
||||
全部
|
||||
</Tag.CheckableTag>
|
||||
{availableTags.map((tag) => (
|
||||
<Tag.CheckableTag
|
||||
key={tag}
|
||||
checked={selectedTags.includes(tag)}
|
||||
className={cn("prompt-filter-tag", selectedTags.includes(tag) && "is-active")}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
toggleTag(tag);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4 2xl:grid-cols-5">
|
||||
{items.map((asset) => (
|
||||
<LibraryCard key={asset.id} asset={asset} onOpen={() => setSelectedAsset(asset)} onAdd={() => void saveToMyAssets(asset)} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!items.length ? <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有找到素材" className="py-20" /> : null}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Pagination current={page} pageSize={PAGE_SIZE} total={total} showSizeChanger={false} onChange={(nextPage) => setPage(nextPage)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Drawer title="素材详情" open={Boolean(selectedAsset)} size="large" onClose={() => setSelectedAsset(null)}>
|
||||
{selectedAsset ? (
|
||||
<div className="space-y-5">
|
||||
{selectedAsset.coverUrl ? (
|
||||
<Image src={selectedAsset.coverUrl} alt={selectedAsset.title} className="rounded-lg" />
|
||||
) : (
|
||||
<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{selectedAsset.content || "暂无封面"}</div>
|
||||
)}
|
||||
<div>
|
||||
<Typography.Title level={4} className="!mb-2">
|
||||
{selectedAsset.title}
|
||||
</Typography.Title>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Tag>{selectedAsset.type === "image" ? "图片" : "文本"}</Tag>
|
||||
{selectedAsset.tags.map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
|
||||
<Typography.Text type="secondary" className="block text-xs">
|
||||
内容
|
||||
</Typography.Text>
|
||||
{selectedAsset.type === "text" ? <Typography.Paragraph className="mt-2 whitespace-pre-wrap">{selectedAsset.content}</Typography.Paragraph> : <Typography.Text className="mt-2 block">{selectedAsset.url}</Typography.Text>}
|
||||
</div>
|
||||
{selectedAsset.description ? <Typography.Paragraph type="secondary">{selectedAsset.description}</Typography.Paragraph> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAsset.type === "text" ? (
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.content)}>
|
||||
复制文本
|
||||
</Button>
|
||||
) : null}
|
||||
{selectedAsset.type === "image" ? (
|
||||
<Button type="primary" icon={<Copy className="size-4" />} onClick={() => copyText(selectedAsset.url)}>
|
||||
复制链接
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<FolderPlus className="size-4" />} onClick={() => void saveToMyAssets(selectedAsset)}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LibraryCard({ asset, onOpen, onAdd }: { asset: AssetLibraryItem; onOpen: () => void; onAdd: () => void }) {
|
||||
const cover = asset.coverUrl;
|
||||
return (
|
||||
<Card
|
||||
hoverable
|
||||
className="overflow-hidden"
|
||||
styles={{ body: { padding: 0 } }}
|
||||
cover={
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
{cover ? (
|
||||
<img src={cover} alt={asset.title} className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm leading-6 text-stone-600 dark:bg-stone-900 dark:text-stone-300">{asset.content || "暂无封面"}</div>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<button type="button" className="block w-full text-left" onClick={onOpen}>
|
||||
<div className="p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="line-clamp-1 text-sm font-semibold text-stone-950 dark:text-stone-100">{asset.title}</h2>
|
||||
<Tag className="m-0 shrink-0 text-[11px]">{asset.type === "image" ? "图片" : "文本"}</Tag>
|
||||
</div>
|
||||
<Typography.Paragraph type="secondary" ellipsis={{ rows: 3 }} className="!mb-0 !mt-2 !text-xs !leading-5">
|
||||
{asset.type === "text" ? asset.content : asset.url}
|
||||
</Typography.Paragraph>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{asset.tags.slice(0, 3).map((tag) => (
|
||||
<Tag key={tag} className="m-0 text-[11px]">
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{!asset.tags.length ? <Tag className="m-0 text-[11px]">无标签</Tag> : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 px-4 pb-4">
|
||||
<Button size="small" onClick={onOpen}>
|
||||
查看
|
||||
</Button>
|
||||
<Button size="small" icon={<FolderPlus className="size-3.5" />} onClick={onAdd}>
|
||||
加入我的素材
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
async function remoteImageToDataUrl(url: string) {
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
const blob = response.data as Blob;
|
||||
return await blobToDataUrl(blob);
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { BookOpen, Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
||||
import { BookOpen, Bot, Home, ImageIcon, Images, List, Menu, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
@@ -19,30 +19,33 @@ import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import { UserStatusActions } from "@/components/layout/user-status-actions";
|
||||
import { useAssetStore } from "@/stores/use-asset-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { cropDataUrl, upscaleDataUrl } from "../utils/canvas-image-data";
|
||||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "../utils/canvas-image-data";
|
||||
import { fitNodeSize, nodeSizeFromRatio } from "../utils/canvas-node-size";
|
||||
import { App, Button, Dropdown, Modal } from "antd";
|
||||
import { NODE_DEFAULT_SIZE, getNodeSpec } from "../constants";
|
||||
import { ActiveConnectionPath, ConnectionPath } from "../components/canvas-connections";
|
||||
import { CanvasConfigComposer } from "../components/canvas-config-composer";
|
||||
import { CanvasConfigNodePanel } from "../components/canvas-config-node-panel";
|
||||
import { CanvasAssistantPanel } from "../components/canvas-assistant-panel";
|
||||
import { CANVAS_AGENT_PANEL_MOTION_MS, CanvasAssistantPanel } from "../components/canvas-assistant-panel";
|
||||
import { CanvasNodeContextMenu } from "../components/canvas-context-menu";
|
||||
import { CanvasNodeAngleDialog, type CanvasImageAngleParams } from "../components/canvas-node-angle-dialog";
|
||||
import { CanvasNodeCropDialog, type CanvasImageCropRect } from "../components/canvas-node-crop-dialog";
|
||||
import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "../components/canvas-node-mask-edit-dialog";
|
||||
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "../components/canvas-node-split-dialog";
|
||||
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "../components/canvas-node-upscale-dialog";
|
||||
import { buildNodeChatMessages, buildNodeGenerationContext, buildNodeGenerationInputs, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation";
|
||||
import { buildNodeGenerationContext, buildNodeGenerationInputs, buildNodeResponseMessages, hydrateNodeGenerationContext, type NodeGenerationInput } from "../components/canvas-node-generation";
|
||||
import { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar";
|
||||
import { InfiniteCanvas } from "../components/infinite-canvas";
|
||||
import { Minimap } from "../components/canvas-mini-map";
|
||||
import { CanvasNode } from "../components/canvas-node";
|
||||
import { CanvasNodePromptPanel, type CanvasNodeGenerationMode } from "../components/canvas-node-prompt-panel";
|
||||
import { CanvasToolbar } from "../components/canvas-toolbar";
|
||||
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
|
||||
import { AssetPickerModal, type InsertAssetPayload } from "../components/asset-picker-modal";
|
||||
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
|
||||
import { useCanvasStore } from "../stores/use-canvas-store";
|
||||
import { applyCanvasAgentOps, type CanvasAgentOp, type CanvasAgentSnapshot } from "../utils/canvas-agent-ops";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
||||
import type { CanvasAgentMode } from "../components/canvas-agent-chat-ui";
|
||||
import {
|
||||
CanvasNodeType,
|
||||
type CanvasAssistantImage,
|
||||
@@ -272,7 +275,6 @@ function InfiniteCanvasPage() {
|
||||
const [showImageInfo, setShowImageInfo] = useState(false);
|
||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||
const [assetPickerOpen, setAssetPickerOpen] = useState(false);
|
||||
const [assetPickerTab, setAssetPickerTab] = useState<AssetPickerTab>("my-assets");
|
||||
const [projectLoaded, setProjectLoaded] = useState(false);
|
||||
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
||||
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
|
||||
@@ -282,12 +284,16 @@ function InfiniteCanvasPage() {
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
||||
const [maskEditNodeId, setMaskEditNodeId] = useState<string | null>(null);
|
||||
const [splitNodeId, setSplitNodeId] = useState<string | null>(null);
|
||||
const [upscaleNodeId, setUpscaleNodeId] = useState<string | null>(null);
|
||||
const [superResolveNodeId, setSuperResolveNodeId] = useState<string | null>(null);
|
||||
const [angleNodeId, setAngleNodeId] = useState<string | null>(null);
|
||||
const [previewNodeId, setPreviewNodeId] = useState<string | null>(null);
|
||||
const [assistantCollapsed, setAssistantCollapsed] = useState(true);
|
||||
const [assistantMounted, setAssistantMounted] = useState(false);
|
||||
const [assistantClosing, setAssistantClosing] = useState(false);
|
||||
const [agentMode, setAgentMode] = useState<CanvasAgentMode>("online");
|
||||
const [agentUndoSnapshot, setAgentUndoSnapshot] = useState<CanvasAgentSnapshot | null>(null);
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState("");
|
||||
const [historyState, setHistoryState] = useState({ canUndo: false, canRedo: false });
|
||||
@@ -299,9 +305,11 @@ function InfiniteCanvasPage() {
|
||||
const connectionsRef = useRef(connections);
|
||||
const selectedNodeIdsRef = useRef(selectedNodeIds);
|
||||
const viewportRef = useRef(viewport);
|
||||
const generateNodeRef = useRef<((nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => Promise<void>) | null>(null);
|
||||
const connectingParamsRef = useRef(connectingParams);
|
||||
const connectionTargetNodeIdRef = useRef(connectionTargetNodeId);
|
||||
const selectionBoxRef = useRef(selectionBox);
|
||||
const agentCloseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingConnectionCreateRef = useRef(pendingConnectionCreate);
|
||||
|
||||
const createHistoryEntry = useCallback(
|
||||
@@ -387,6 +395,13 @@ function InfiniteCanvasPage() {
|
||||
};
|
||||
}, [activeChatId, backgroundMode, chatSessions, connections, createHistoryEntry, nodes, projectLoaded, showImageInfo]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (agentCloseTimerRef.current) clearTimeout(agentCloseTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || historyPausedRef.current) return;
|
||||
updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode, showImageInfo });
|
||||
@@ -586,6 +601,7 @@ function InfiniteCanvasPage() {
|
||||
const infoNode = infoNodeId ? nodeById.get(infoNodeId) || null : null;
|
||||
const cropNode = cropNodeId ? nodeById.get(cropNodeId) || null : null;
|
||||
const maskEditNode = maskEditNodeId ? nodeById.get(maskEditNodeId) || null : null;
|
||||
const splitNode = splitNodeId ? nodeById.get(splitNodeId) || null : null;
|
||||
const upscaleNode = upscaleNodeId ? nodeById.get(upscaleNodeId) || null : null;
|
||||
const superResolveNode = superResolveNodeId ? nodeById.get(superResolveNodeId) || null : null;
|
||||
const angleNode = angleNodeId ? nodeById.get(angleNodeId) || null : null;
|
||||
@@ -645,6 +661,55 @@ function InfiniteCanvasPage() {
|
||||
nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections)));
|
||||
return map;
|
||||
}, [connections, nodes]);
|
||||
const agentSnapshot = useMemo<CanvasAgentSnapshot>(
|
||||
() => ({ projectId, title: currentProject?.title || "未命名画布", nodes, connections, selectedNodeIds: Array.from(selectedNodeIds), viewport }),
|
||||
[connections, currentProject?.title, nodes, projectId, selectedNodeIds, viewport],
|
||||
);
|
||||
const applyAgentOps = useCallback(
|
||||
(ops?: CanvasAgentOp[]) => {
|
||||
const safeOps = Array.isArray(ops) ? ops.filter((op) => op?.type) : [];
|
||||
const before = { projectId, title: currentProject?.title || "未命名画布", nodes: nodesRef.current, connections: connectionsRef.current, selectedNodeIds: Array.from(selectedNodeIdsRef.current), viewport: viewportRef.current };
|
||||
const generationOps = safeOps.filter((op): op is Extract<CanvasAgentOp, { type: "run_generation" }> => op.type === "run_generation" && Boolean(op.nodeId));
|
||||
const next = applyCanvasAgentOps(before, safeOps.filter((op) => op.type !== "run_generation"));
|
||||
nodesRef.current = next.nodes;
|
||||
connectionsRef.current = next.connections;
|
||||
selectedNodeIdsRef.current = new Set(next.selectedNodeIds);
|
||||
viewportRef.current = next.viewport;
|
||||
setAgentUndoSnapshot(before);
|
||||
setNodes(next.nodes);
|
||||
setConnections(next.connections);
|
||||
setSelectedNodeIds(new Set(next.selectedNodeIds));
|
||||
setSelectedConnectionId(null);
|
||||
setViewport(next.viewport);
|
||||
setContextMenu(null);
|
||||
if (generationOps.length) {
|
||||
queueMicrotask(() =>
|
||||
generationOps.forEach((op) => {
|
||||
const target = nodesRef.current.find((node) => node.id === op.nodeId);
|
||||
const prompt = op.prompt?.trim() ? op.prompt : target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "";
|
||||
void generateNodeRef.current?.(op.nodeId, op.mode || target?.metadata?.generationMode || "image", prompt);
|
||||
}),
|
||||
);
|
||||
}
|
||||
return { ...next, projectId, title: currentProject?.title || "未命名画布" };
|
||||
},
|
||||
[currentProject?.title, projectId],
|
||||
);
|
||||
const undoAgentOps = useCallback(() => {
|
||||
if (!agentUndoSnapshot) return null;
|
||||
nodesRef.current = agentUndoSnapshot.nodes;
|
||||
connectionsRef.current = agentUndoSnapshot.connections;
|
||||
selectedNodeIdsRef.current = new Set(agentUndoSnapshot.selectedNodeIds);
|
||||
viewportRef.current = agentUndoSnapshot.viewport;
|
||||
setNodes(agentUndoSnapshot.nodes);
|
||||
setConnections(agentUndoSnapshot.connections);
|
||||
setSelectedNodeIds(new Set(agentUndoSnapshot.selectedNodeIds));
|
||||
setSelectedConnectionId(null);
|
||||
setViewport(agentUndoSnapshot.viewport);
|
||||
setContextMenu(null);
|
||||
setAgentUndoSnapshot(null);
|
||||
return { ...agentUndoSnapshot, projectId, title: currentProject?.title || "未命名画布" };
|
||||
}, [agentUndoSnapshot, currentProject?.title, projectId]);
|
||||
const createNode = useCallback(
|
||||
(type: CanvasNodeType, position?: Position) => {
|
||||
const targetPosition = position || getCanvasCenter();
|
||||
@@ -1519,6 +1584,44 @@ function InfiniteCanvasPage() {
|
||||
setCropNodeId(null);
|
||||
}, []);
|
||||
|
||||
const splitImageNode = useCallback(
|
||||
async (node: CanvasNodeData, params: CanvasImageSplitParams) => {
|
||||
if (!node.metadata?.content) return;
|
||||
setSplitNodeId(null);
|
||||
const pieces = await splitDataUrl(node.metadata.content, params);
|
||||
const gap = 16;
|
||||
const cellWidth = node.width / params.columns;
|
||||
const cellHeight = node.height / params.rows;
|
||||
const startX = node.position.x + node.width + 96;
|
||||
const startY = node.position.y;
|
||||
const childNodes = await Promise.all(
|
||||
pieces.map(async (piece) => {
|
||||
const image = await uploadImage(piece.dataUrl);
|
||||
const id = nanoid();
|
||||
return {
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: `${node.title || "图片"} ${piece.row + 1}-${piece.column + 1}`,
|
||||
position: { x: startX + piece.column * (cellWidth + gap), y: startY + piece.row * (cellHeight + gap) },
|
||||
width: cellWidth,
|
||||
height: cellHeight,
|
||||
metadata: {
|
||||
...imageMetadata(image),
|
||||
prompt: node.metadata?.prompt,
|
||||
},
|
||||
} satisfies CanvasNodeData;
|
||||
}),
|
||||
);
|
||||
setNodes((prev) => [...prev, ...childNodes]);
|
||||
setConnections((prev) => [...prev, ...childNodes.map((child) => ({ id: nanoid(), fromNodeId: node.id, toNodeId: child.id }))]);
|
||||
setSelectedNodeIds(new Set(childNodes.map((child) => child.id)));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(null);
|
||||
message.success(`已切分为 ${childNodes.length} 个子节点`);
|
||||
},
|
||||
[message],
|
||||
);
|
||||
|
||||
const maskEditImageNode = useCallback(
|
||||
async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
@@ -2021,7 +2124,7 @@ function InfiniteCanvasPage() {
|
||||
const answers = await Promise.all(
|
||||
(childIds.length ? childIds : [nodeId]).map((targetNodeId) => {
|
||||
let localStreamed = "";
|
||||
return requestImageQuestion(generationConfig, buildNodeChatMessages({ ...generationContext, prompt: effectivePrompt }), (text) => {
|
||||
return requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...generationContext, prompt: effectivePrompt }), (text) => {
|
||||
localStreamed = text;
|
||||
streamed = text;
|
||||
if (isConfigNode) return;
|
||||
@@ -2053,6 +2156,9 @@ function InfiniteCanvasPage() {
|
||||
},
|
||||
[effectiveConfig, openConfigDialog],
|
||||
);
|
||||
useEffect(() => {
|
||||
generateNodeRef.current = handleGenerateNode;
|
||||
}, [handleGenerateNode]);
|
||||
|
||||
const handleRetryNode = useCallback(
|
||||
async (node: CanvasNodeData) => {
|
||||
@@ -2099,7 +2205,7 @@ function InfiniteCanvasPage() {
|
||||
if (node.type === CanvasNodeType.Text) {
|
||||
if (!context) return;
|
||||
let streamed = "";
|
||||
const answer = await requestImageQuestion(generationConfig, buildNodeChatMessages({ ...context, prompt }), (text) => {
|
||||
const answer = await requestImageQuestion(generationConfig, buildNodeResponseMessages({ ...context, prompt }), (text) => {
|
||||
streamed = text;
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, type: CanvasNodeType.Text, metadata: { ...item.metadata, content: text, status: NODE_STATUS_LOADING } } : item)));
|
||||
});
|
||||
@@ -2245,6 +2351,28 @@ function InfiniteCanvasPage() {
|
||||
[insertAssistantImage, insertAssistantText, screenToCanvas, size.height, size.width],
|
||||
);
|
||||
|
||||
const assistantOpen = assistantMounted && !assistantCollapsed;
|
||||
const openAgent = (mode: CanvasAgentMode = agentMode) => {
|
||||
if (agentCloseTimerRef.current) {
|
||||
clearTimeout(agentCloseTimerRef.current);
|
||||
agentCloseTimerRef.current = null;
|
||||
}
|
||||
setAgentMode(mode);
|
||||
setAssistantMounted(true);
|
||||
setAssistantClosing(false);
|
||||
setAssistantCollapsed(false);
|
||||
};
|
||||
const closeAgent = () => {
|
||||
if (!assistantMounted || assistantClosing) return;
|
||||
setAssistantCollapsed(true);
|
||||
setAssistantClosing(true);
|
||||
agentCloseTimerRef.current = setTimeout(() => {
|
||||
agentCloseTimerRef.current = null;
|
||||
setAssistantMounted(false);
|
||||
setAssistantClosing(false);
|
||||
}, CANVAS_AGENT_PANEL_MOTION_MS);
|
||||
};
|
||||
|
||||
if (!projectLoaded) return <CanvasRefreshShell />;
|
||||
|
||||
return (
|
||||
@@ -2267,11 +2395,8 @@ function InfiniteCanvasPage() {
|
||||
onImportImage={() => handleUploadRequest()}
|
||||
onUndo={undoCanvas}
|
||||
onRedo={redoCanvas}
|
||||
assistantCollapsed={assistantCollapsed}
|
||||
onExpandAssistant={() => {
|
||||
setAssistantMounted(true);
|
||||
setAssistantCollapsed(false);
|
||||
}}
|
||||
agentOpen={assistantOpen}
|
||||
onToggleAgent={() => (assistantOpen ? closeAgent() : openAgent())}
|
||||
/>
|
||||
|
||||
<InfiniteCanvas
|
||||
@@ -2396,6 +2521,7 @@ function InfiniteCanvasPage() {
|
||||
onSetBatchPrimary={setBatchPrimary}
|
||||
onRetry={(node) => void handleRetryNode(node)}
|
||||
onGenerateImage={generateImageFromTextNode}
|
||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||
onContextMenu={(event, id) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -2436,6 +2562,7 @@ function InfiniteCanvasPage() {
|
||||
onSaveAsset={(node) => void saveNodeAsset(node)}
|
||||
onMaskEdit={(node) => setMaskEditNodeId(node.id)}
|
||||
onCrop={(node) => setCropNodeId(node.id)}
|
||||
onSplit={(node) => setSplitNodeId(node.id)}
|
||||
onUpscale={(node) => setUpscaleNodeId(node.id)}
|
||||
onSuperResolve={(node) => setSuperResolveNodeId(node.id)}
|
||||
onAngle={(node) => setAngleNodeId(node.id)}
|
||||
@@ -2465,12 +2592,7 @@ function InfiniteCanvasPage() {
|
||||
onDeselect={deselectCanvas}
|
||||
onBackgroundModeChange={setBackgroundMode}
|
||||
onShowImageInfoChange={setShowImageInfo}
|
||||
onOpenAssetLibrary={() => {
|
||||
setAssetPickerTab("library");
|
||||
setAssetPickerOpen(true);
|
||||
}}
|
||||
onOpenMyAssets={() => {
|
||||
setAssetPickerTab("my-assets");
|
||||
setAssetPickerOpen(true);
|
||||
}}
|
||||
/>
|
||||
@@ -2507,6 +2629,8 @@ function InfiniteCanvasPage() {
|
||||
|
||||
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open={Boolean(maskEditNode)} onClose={() => setMaskEditNodeId(null)} onConfirm={(payload) => void maskEditImageNode(maskEditNode!, payload)} /> : null}
|
||||
|
||||
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open={Boolean(splitNode)} onClose={() => setSplitNodeId(null)} onConfirm={(params) => void splitImageNode(splitNode!, params)} /> : null}
|
||||
|
||||
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open={Boolean(upscaleNode)} onClose={() => setUpscaleNodeId(null)} onConfirm={(params) => void upscaleImageNode(upscaleNode!, params)} /> : null}
|
||||
|
||||
<Modal title="AI 超分" open={Boolean(superResolveNode?.metadata?.content)} centered footer={null} onCancel={() => setSuperResolveNodeId(null)}>
|
||||
@@ -2550,21 +2674,25 @@ function InfiniteCanvasPage() {
|
||||
<p className="text-sm opacity-60">这会删除当前画布上的所有节点和连线。</p>
|
||||
</Modal>
|
||||
|
||||
<AssetPickerModal open={assetPickerOpen} defaultTab={assetPickerTab} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
|
||||
<AssetPickerModal open={assetPickerOpen} onInsert={handleAssetInsert} onClose={() => setAssetPickerOpen(false)} />
|
||||
</section>
|
||||
{assistantMounted ? (
|
||||
<CanvasAssistantPanel
|
||||
nodes={nodes}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
snapshot={agentSnapshot}
|
||||
sessions={chatSessions}
|
||||
activeSessionId={activeChatId}
|
||||
onSelectNodeIds={setSelectedNodeIds}
|
||||
onSessionsChange={handleAssistantSessionsChange}
|
||||
onInsertImage={insertAssistantImage}
|
||||
onInsertText={insertAssistantText}
|
||||
onApplyOps={applyAgentOps}
|
||||
canUndoOps={Boolean(agentUndoSnapshot)}
|
||||
onUndoOps={undoAgentOps}
|
||||
onPasteImage={pasteAssistantImage}
|
||||
onCollapseStart={() => setAssistantCollapsed(true)}
|
||||
onCollapse={() => setAssistantMounted(false)}
|
||||
agentMode={agentMode}
|
||||
onAgentModeChange={setAgentMode}
|
||||
closing={assistantClosing}
|
||||
onCollapse={closeAgent}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
@@ -2588,8 +2716,8 @@ function CanvasTopBar({
|
||||
onImportImage,
|
||||
onUndo,
|
||||
onRedo,
|
||||
assistantCollapsed,
|
||||
onExpandAssistant,
|
||||
agentOpen,
|
||||
onToggleAgent,
|
||||
}: {
|
||||
title: string;
|
||||
titleDraft: string;
|
||||
@@ -2607,15 +2735,13 @@ function CanvasTopBar({
|
||||
onImportImage: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
assistantCollapsed: boolean;
|
||||
onExpandAssistant: () => void;
|
||||
agentOpen: boolean;
|
||||
onToggleAgent: () => void;
|
||||
}) {
|
||||
const colorTheme = useThemeStore((state) => state.theme);
|
||||
const theme = canvasThemes[colorTheme];
|
||||
const titleRef = useRef<HTMLDivElement>(null);
|
||||
const accountRef = useRef<HTMLDivElement>(null);
|
||||
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||
const [accountOpen, setAccountOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isTitleEditing) return;
|
||||
@@ -2626,15 +2752,6 @@ function CanvasTopBar({
|
||||
return () => document.removeEventListener("pointerdown", close, true);
|
||||
}, [isTitleEditing, onFinishTitleEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accountOpen) return;
|
||||
const close = (event: PointerEvent) => {
|
||||
if (!accountRef.current?.contains(event.target as Node)) setAccountOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", close, true);
|
||||
return () => document.removeEventListener("pointerdown", close, true);
|
||||
}, [accountOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 z-50 flex h-16 items-center justify-between px-4">
|
||||
@@ -2692,29 +2809,18 @@ function CanvasTopBar({
|
||||
<div className="pointer-events-auto flex items-center gap-1.5">
|
||||
<UserStatusActions
|
||||
variant="canvas"
|
||||
accountOpen={accountOpen}
|
||||
onAccountOpenChange={setAccountOpen}
|
||||
accountRef={accountRef}
|
||||
getPopupContainer={(node) => node.parentElement || document.body}
|
||||
onOpenShortcuts={() => {
|
||||
setShortcutsOpen(true);
|
||||
setAccountOpen(false);
|
||||
}}
|
||||
onOpenShortcuts={() => setShortcutsOpen(true)}
|
||||
/>
|
||||
{assistantCollapsed ? (
|
||||
<>
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-10 !rounded-xl !px-3 !font-medium"
|
||||
style={{ background: theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
|
||||
icon={<MessageSquare className="size-4" />}
|
||||
onClick={onExpandAssistant}
|
||||
>
|
||||
助手
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
<span className="h-6 w-px" style={{ background: theme.toolbar.border }} />
|
||||
<Button
|
||||
type="text"
|
||||
className="!h-10 !rounded-xl !px-3 !font-medium"
|
||||
style={{ background: agentOpen ? theme.toolbar.activeBg : theme.toolbar.panel, color: theme.node.text, boxShadow: "0 10px 30px rgba(28,25,23,.10)" }}
|
||||
icon={<Bot className="size-4" />}
|
||||
onClick={onToggleAgent}
|
||||
>
|
||||
Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Modal title="快捷键" open={shortcutsOpen} onCancel={() => setShortcutsOpen(false)} footer={null} centered>
|
||||
@@ -2867,7 +2973,6 @@ async function hydrateAssistantImages(sessions: CanvasAssistantSession[]) {
|
||||
session.messages.map(async (message) => ({
|
||||
...message,
|
||||
references: await Promise.all((message.references || []).map(hydrateItem)),
|
||||
images: await Promise.all((message.images || []).map(hydrateItem)),
|
||||
})),
|
||||
),
|
||||
})),
|
||||
|
||||
@@ -1,43 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { App, Empty, Input, Modal, Pagination, Spin, Tabs, Tag } from "antd";
|
||||
import { Empty, Input, Modal, Pagination, Tag } from "antd";
|
||||
import { Search } from "lucide-react";
|
||||
import axios from "axios";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAssetStore, type Asset } from "@/stores/use-asset-store";
|
||||
import { fetchAssetLibrary, type AssetLibraryItem } from "@/services/api/assets";
|
||||
|
||||
export type AssetPickerTab = "my-assets" | "library";
|
||||
|
||||
export type InsertAssetPayload = { kind: "text"; content: string; title: string } | { kind: "image"; dataUrl: string; title: string; storageKey?: string } | { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number };
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
defaultTab?: AssetPickerTab;
|
||||
onInsert: (payload: InsertAssetPayload) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function AssetPickerModal({ open, defaultTab = "my-assets", onInsert, onClose }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<AssetPickerTab>(defaultTab);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setActiveTab(defaultTab);
|
||||
}, [open, defaultTab]);
|
||||
|
||||
export function AssetPickerModal({ open, onInsert, onClose }: Props) {
|
||||
return (
|
||||
<Modal title="选择素材" open={open} onCancel={onClose} footer={null} width={860} destroyOnHidden styles={{ body: { padding: "0 24px 24px", minHeight: 480 } }}>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => setActiveTab(key as AssetPickerTab)}
|
||||
items={[
|
||||
{ key: "my-assets", label: "我的素材", children: <MyAssetsTab onInsert={onInsert} /> },
|
||||
{ key: "library", label: "素材库", children: <LibraryTab onInsert={onInsert} /> },
|
||||
]}
|
||||
/>
|
||||
<MyAssetsTab onInsert={onInsert} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -51,104 +32,12 @@ const kindOptions = [
|
||||
{ label: "视频", value: "video" },
|
||||
];
|
||||
|
||||
function LibraryTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [kindFilter, setKindFilter] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [inserting, setInserting] = useState<string | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["asset-picker-library", keyword, kindFilter, page],
|
||||
queryFn: () => fetchAssetLibrary({ keyword, type: kindFilter, page, pageSize: PAGE_SIZE }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const items = query.data?.items || [];
|
||||
const total = query.data?.total || 0;
|
||||
|
||||
const handleInsert = async (asset: AssetLibraryItem) => {
|
||||
try {
|
||||
setInserting(asset.id);
|
||||
if (asset.type === "text") {
|
||||
onInsert({ kind: "text", content: asset.content, title: asset.title });
|
||||
} else {
|
||||
const dataUrl = await remoteImageToDataUrl(asset.url);
|
||||
onInsert({ kind: "image", dataUrl, title: asset.title });
|
||||
}
|
||||
} catch {
|
||||
message.error("插入失败");
|
||||
} finally {
|
||||
setInserting(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
className="w-56"
|
||||
size="small"
|
||||
prefix={<Search className="size-3.5 text-stone-400" />}
|
||||
placeholder="搜索素材"
|
||||
value={keyword}
|
||||
allowClear
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setKeyword(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1.5">
|
||||
{[
|
||||
{ label: "全部", value: "" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
].map((opt) => (
|
||||
<Tag.CheckableTag
|
||||
key={opt.value || "all"}
|
||||
checked={kindFilter === opt.value}
|
||||
className={cn("prompt-filter-tag", kindFilter === opt.value && "is-active")}
|
||||
onChange={() => {
|
||||
setPage(1);
|
||||
setKindFilter(opt.value);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Tag.CheckableTag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading ? (
|
||||
<div className="flex justify-center py-16">
|
||||
<Spin />
|
||||
</div>
|
||||
) : items.length ? (
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{items.map((asset) => (
|
||||
<PickerCard key={asset.id} title={asset.title} kind={asset.type} cover={asset.coverUrl} loading={inserting === asset.id} onClick={() => void handleInsert(asset)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有素材" className="py-12" />
|
||||
)}
|
||||
|
||||
{total > PAGE_SIZE && (
|
||||
<div className="flex justify-center">
|
||||
<Pagination size="small" current={page} pageSize={PAGE_SIZE} total={total} onChange={setPage} showSizeChanger={false} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PickerCard({ title, kind, cover, loading, onClick }: { title: string; kind: string; cover: string; loading?: boolean; onClick: () => void }) {
|
||||
function PickerCard({ title, kind, cover, onClick }: { title: string; kind: string; cover: string; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="group relative cursor-pointer overflow-hidden rounded-lg border border-stone-200 bg-white text-left transition hover:border-stone-400 hover:shadow-md dark:border-stone-700 dark:bg-stone-900 dark:hover:border-stone-500"
|
||||
onClick={onClick}
|
||||
disabled={loading}
|
||||
>
|
||||
{cover ? (
|
||||
<img src={cover} alt={title} className="aspect-[4/3] w-full object-cover" />
|
||||
@@ -161,27 +50,11 @@ function PickerCard({ title, kind, cover, loading, onClick }: { title: string; k
|
||||
<Tag className="m-0 shrink-0 text-[10px]">{kind === "image" ? "图片" : kind === "video" ? "视频" : "文本"}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 dark:bg-stone-900/60">
|
||||
<Spin size="small" />
|
||||
</div>
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-stone-950/0 text-sm font-medium text-white opacity-0 transition group-hover:bg-stone-950/55 group-hover:opacity-100">插入</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
async function remoteImageToDataUrl(url: string) {
|
||||
const response = await axios.get(url, { responseType: "blob" });
|
||||
const blob = response.data as Blob;
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
function MyAssetsTab({ onInsert }: { onInsert: (payload: InsertAssetPayload) => void }) {
|
||||
const assets = useAssetStore((state) => state.assets);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user