mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa3fad684 | |||
| 22f256d1be | |||
| 8d0b5ecdb8 | |||
| 373229a04a | |||
| 39a1e859b1 | |||
| dd9347e85d | |||
| 13418a4d26 | |||
| 506378c308 | |||
| 81b9a207b9 | |||
| 3667557dc2 | |||
| b05d759f20 | |||
| 6d3b89c842 | |||
| eb050fc08a | |||
| 72024c8138 | |||
| 51df099936 | |||
| 96cca4d97c | |||
| 75d5af5d8c | |||
| 8a51cc609f | |||
| 0adf547db9 | |||
| 163dc51203 | |||
| 68aca6fa16 | |||
| ceb7605a1e | |||
| 76a4e7c4a2 | |||
| c70e7af14d | |||
| 72ed6a043c | |||
| 8f19a18c35 | |||
| 38ec654748 | |||
| 39793e890d | |||
| 7bfa5a2bd9 | |||
| 98a229af5c | |||
| d12cceeecf | |||
| 50176d8c7a | |||
| e926223385 | |||
| 965270b6e3 | |||
| 6413011e54 | |||
| 7802771e56 | |||
| b16251c1ed | |||
| c7e3e0af6a | |||
| 5f59b4bed3 | |||
| 5ca5b72b01 |
+5
-1
@@ -1,7 +1,11 @@
|
||||
.git
|
||||
.idea
|
||||
docs
|
||||
docs2
|
||||
data
|
||||
docs/.next
|
||||
docs/.source
|
||||
docs/node_modules
|
||||
docs/out
|
||||
web/.next
|
||||
web/node_modules
|
||||
web/out
|
||||
|
||||
+6
-2
@@ -6,8 +6,9 @@ ADMIN_PASSWORD=infinite-canvas
|
||||
JWT_SECRET=infinite-canvas
|
||||
JWT_EXPIRE_HOURS=168
|
||||
|
||||
# 后端监听端口
|
||||
PORT=8080
|
||||
# 后端默认监听 8080,如需本地开发修改端口再取消注释。
|
||||
# Docker 镜像内前端固定监听 3000,避免覆盖前端 PORT。
|
||||
# PORT=8080
|
||||
|
||||
# 公开访问地址,用于把本地上传的 Seedance 参考图/视频暴露给火山方舟拉取。
|
||||
# 线上部署时填写站点根地址,例如:https://your-domain.example.com
|
||||
@@ -15,11 +16,14 @@ PORT=8080
|
||||
|
||||
# 前端开发代理默认使用 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[@]}"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: Docs Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/basketikun/infinite-canvas-docs
|
||||
|
||||
jobs:
|
||||
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
|
||||
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docs/Dockerfile
|
||||
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[@]}"
|
||||
@@ -67,14 +67,15 @@
|
||||
## 文档规范
|
||||
|
||||
- README 保持简洁,只放项目介绍、核心功能、快速开始和文档入口。
|
||||
- 详细功能介绍写到 `docs/features.md`。
|
||||
- 后续待办写到 `docs/todo.md`。
|
||||
- 已实现但还需要用户测试确认的事项写到 `docs/pending-test.md`。
|
||||
- `docs/pending-test.md` 用来记录这个版本实际做了哪些可测试变更;`CHANGELOG.md` 的 `Unreleased` 只保留对这些变更的版本级归纳,避免逐条照搬实现细节。
|
||||
- 每次 todo 事项完成后,先从 `docs/todo.md` 移到 `docs/pending-test.md`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/features.md`。
|
||||
- 每次任务完成前,都要根据实际变更检查并更新 `docs/todo.md` 和 `docs/pending-test.md`;如果功能或待办没有变化,也要确认无需修改。
|
||||
- 接口响应规则写到 `docs/api-response.md`。
|
||||
- 数据库结构写到 `docs/backend-database.md`。
|
||||
- `docs/index.md` 放给 AI 使用的文档索引,不要再放到 `docs/content/docs/` 内容目录里。
|
||||
- 详细功能介绍写到 `docs/content/docs/overview/features.mdx`。
|
||||
- 后续待办写到 `docs/content/docs/progress/todo.mdx`。
|
||||
- 已实现但还需要用户测试确认的事项写到 `docs/content/docs/progress/pending-test.mdx`。
|
||||
- `docs/content/docs/progress/pending-test.mdx` 用来记录这个版本实际做了哪些可测试变更;`CHANGELOG.md` 的 `Unreleased` 只保留对这些变更的版本级归纳,避免逐条照搬实现细节。
|
||||
- 每次 todo 事项完成后,先从 `docs/content/docs/progress/todo.mdx` 移到 `docs/content/docs/progress/pending-test.mdx`,不要直接写进正式功能说明;用户确认测试通过后再更新 `docs/content/docs/overview/features.mdx`。
|
||||
- 每次任务完成前,都要根据实际变更检查并更新 `docs/content/docs/progress/todo.mdx` 和 `docs/content/docs/progress/pending-test.mdx`;如果功能或待办没有变化,也要确认无需修改。
|
||||
- 接口响应规则写到 `docs/content/docs/backend/api-response.mdx`。
|
||||
- 数据库结构写到 `docs/content/docs/backend/backend-database.mdx`。
|
||||
- 文档不要写过期日期;除非用户明确要求记录具体时间。
|
||||
|
||||
## 发版本流程
|
||||
|
||||
+31
-6
@@ -2,14 +2,39 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v0.2.5 - 2026-06-08
|
||||
|
||||
+ [新增] 新增图片切图功能。
|
||||
+ [新增] 支持webdav同步数据。
|
||||
+ [修复] 修复画布文字节点错误问题。
|
||||
|
||||
## v0.2.4 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片反推提示词功能。
|
||||
|
||||
## v0.2.3 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片蒙版局部修改功能。
|
||||
+ [优化] 优化配置节点@图片功能。
|
||||
|
||||
## v0.2.2 - 2026-06-04
|
||||
|
||||
+ [新增] 新增图片放大工具。
|
||||
+ [优化] 优化图片工具条,增加自定义功能。
|
||||
+ [修复] 修复端口冲突问题、pg/mysql未初始化问题。
|
||||
|
||||
## v0.2.1 - 2026-06-03
|
||||
|
||||
+ [新增] 新增文档站点页面。
|
||||
+ [优化] 优化画布连线交互。
|
||||
+ [优化] 优化模型选择用户偏好。
|
||||
|
||||
## v0.2.0 - 2026-06-01
|
||||
|
||||
+ [新增] 支持通过火山方舟 Agent Plan 接入 Seedance 2.0 视频生成,后端会自动适配任务创建、轮询和结果回填接口。
|
||||
+ [新增] 视频创作台和画布视频生成支持 Seedance 分辨率、比例、时长、生成声音、水印,以及参考图片、参考视频、参考音频输入。
|
||||
+ [新增] 画布新增音频节点,并新增参考素材上传与公开访问接口,配合 `PUBLIC_BASE_URL` 供火山方舟拉取本地参考素材。
|
||||
+ [优化] 管理后台模型渠道支持 Agent Plan Base URL 归一、Seedance 模型配置提示、渠道探测超时和已启用模型自动同步到公开可用模型。
|
||||
+ [优化] 图片/视频参考素材会按 `图片1`、`视频1`、`音频1` 编号注入提示词,图像请求尺寸和质量参数也会在请求前归一校验。
|
||||
+ [修复] 修复 Docker 部署时 SQLite 相对路径可能写入错误目录的问题,并优化 AI 上游鉴权、限流和 Seedance 敏感参考视频错误提示。
|
||||
+ [新增] 支持通过火山方舟AgentPlan接入。
|
||||
+ [新增] 视频生成支持声音、水印及图片/视频/音频参考输入。
|
||||
+ [新增] 画布新增音频节点。
|
||||
+ [优化] 图片/视频素材支持 `图片1`编号注入提示词。
|
||||
|
||||
## v0.1.1 - 2026-05-30
|
||||
|
||||
|
||||
+8
-3
@@ -3,7 +3,7 @@ FROM oven/bun:1.3.13 AS web-build
|
||||
|
||||
WORKDIR /app/web
|
||||
COPY web/package.json web/bun.lock ./
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --registry=https://registry.npmmirror.com --cache-dir=/root/.bun/install/cache
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --cache-dir=/root/.bun/install/cache
|
||||
COPY VERSION /app/VERSION
|
||||
COPY CHANGELOG.md /app/CHANGELOG.md
|
||||
COPY web ./
|
||||
@@ -31,11 +31,16 @@ 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 /app/web
|
||||
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 && HOSTNAME=0.0.0.0 PORT=3000 npm run start"]
|
||||
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && PORT=3000 node server.js"]
|
||||
|
||||
@@ -4,6 +4,17 @@
|
||||
|
||||
<h1 align="center">无限画布 (infinite-canvas)</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://linux.do/"><img src="https://img.shields.io/badge/Linux.do-Community-2b6de8?style=flat-square" alt="Linux.do"></a>
|
||||
<a href="https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas"><img src="https://img.shields.io/badge/Render-Deploy-46e3b7?style=flat-square&logo=render&logoColor=111111" alt="Deploy to Render"></a>
|
||||
<a href="https://github.com/basketikun/infinite-canvas"><img src="https://img.shields.io/github/stars/basketikun/infinite-canvas?style=flat-square&logo=github" alt="GitHub stars"></a>
|
||||
<a href="VERSION"><img src="https://img.shields.io/badge/version-v0.2.0-2563eb?style=flat-square" alt="Version"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL--3.0-f97316?style=flat-square" alt="License"></a>
|
||||
<a href="https://www.docker.com/"><img src="https://img.shields.io/badge/Docker-ready-2496ed?style=flat-square&logo=docker&logoColor=white" alt="Docker ready"></a>
|
||||
<a href="https://nextjs.org/"><img src="https://img.shields.io/badge/Next.js-16.2-000000?style=flat-square&logo=nextdotjs" alt="Next.js"></a>
|
||||
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.25-00add8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
</p>
|
||||
|
||||
无限画布是一款面向图片创作的开源工作台。它把画布编排、AI 图片生成、参考图编辑、对话助手、提示词库和素材沉淀放在同一个界面里,适合用来探索视觉方案并连续迭代图片结果。
|
||||
|
||||
> [!CAUTION]
|
||||
@@ -18,7 +29,7 @@
|
||||
- 画布助手:围绕选中节点和上游节点对话、生图,并把结果插回画布。
|
||||
- 提示词库:抓取多个 GitHub 开源项目,按案例整理数百个图片提示词。
|
||||
|
||||
完整功能说明见 [docs/features.md](docs/features.md)。
|
||||
完整功能说明见 [docs/features.md](docs2/features.md)。
|
||||
|
||||
如果你在为担心没有合适的生图API来发愁,可以查看该免费生图项目:[chatgpt2api](https://github.com/basketikun/chatgpt2api)
|
||||
|
||||
@@ -81,14 +92,31 @@ https://infinite-canvas-cpco.onrender.com?apiKey={key}&baseUrl={address}
|
||||
|
||||
## 文档
|
||||
|
||||
- [功能介绍](docs/features.md)
|
||||
- [部署说明](docs/deployment.md)
|
||||
- [画布节点操作手册](docs/canvas-node-manual.md)
|
||||
- [画布快捷键](docs/canvas-shortcuts.md)
|
||||
- [待办事项](docs/todo.md)
|
||||
- [后端数据库说明](docs/backend-database.md)
|
||||
- [系统配置数据结构](docs/system-settings.md)
|
||||
- [接口响应约定](docs/api-response.md)
|
||||
- [功能介绍](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)
|
||||
|
||||
## 赞助支持
|
||||
|
||||
<div align="center">
|
||||
|
||||
如果这个项目对你有帮助,欢迎通过爱发电赞助支持,你的每一份鼓励都是持续更新的动力!
|
||||
|
||||
<br>
|
||||
|
||||
<a href="https://ifdian.net/a/basketikun">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
|
||||
<br>
|
||||
<br>
|
||||
|
||||
</div>
|
||||
|
||||
## 社区支持
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.source
|
||||
coverage
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
@@ -0,0 +1,26 @@
|
||||
# deps
|
||||
/node_modules
|
||||
|
||||
# generated content
|
||||
.source
|
||||
|
||||
# test & build
|
||||
/coverage
|
||||
/.next/
|
||||
/out/
|
||||
/build
|
||||
*.tsbuildinfo
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
/.pnp
|
||||
.pnp.js
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# others
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,28 @@
|
||||
FROM oven/bun:1.3.13 AS docs-build
|
||||
|
||||
WORKDIR /app
|
||||
COPY docs/package.json docs/bun.lock ./
|
||||
RUN --mount=type=cache,target=/root/.bun/install/cache bun install --frozen-lockfile --ignore-scripts --cache-dir=/root/.bun/install/cache
|
||||
COPY docs ./
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
RUN bun run postinstall
|
||||
RUN bun run build
|
||||
|
||||
FROM node:22-bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
RUN groupadd --system --gid 1001 nodejs && useradd --system --uid 1001 --gid nodejs nextjs
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/index.md ./index.md
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/content ./content
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/.source ./.source
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/source.config.ts ./source.config.ts
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /CHANGELOG.md /CHANGELOG.md
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=docs-build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,52 @@
|
||||
# docs
|
||||
|
||||
This is a Next.js application generated with
|
||||
[Create Fumadocs](https://github.com/fuma-nama/fumadocs).
|
||||
|
||||
It runs as a server-backed Next.js docs site and is configured for standalone
|
||||
output. Route handlers such as search and LLM text remain available at runtime.
|
||||
|
||||
Run development server:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
Build and run local production server:
|
||||
|
||||
```bash
|
||||
bun run build
|
||||
bun run start
|
||||
```
|
||||
|
||||
Run the published image with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Or build locally with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, `loader()` provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different
|
||||
options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
@@ -1,195 +0,0 @@
|
||||
# 后端数据库说明
|
||||
|
||||
本文档只记录后端当前已经使用的主要数据表。
|
||||
|
||||
## 数据库
|
||||
|
||||
后端使用 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` | 后端模型接口调用失败返还 |
|
||||
+936
@@ -0,0 +1,936 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "docs",
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"fumadocs-core": "16.9.3",
|
||||
"fumadocs-mdx": "15.0.10",
|
||||
"fumadocs-ui": "16.9.3",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.15",
|
||||
"serve": "^14.2.6",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="],
|
||||
|
||||
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
|
||||
|
||||
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
|
||||
|
||||
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
|
||||
|
||||
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
|
||||
|
||||
"@fumadocs/tailwind": ["@fumadocs/tailwind@0.0.5", "", { "peerDependencies": { "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "optionalPeers": ["@tailwindcss/oxide", "tailwindcss"] }, "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@next/env": ["@next/env@16.2.6", "", {}, "sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA=="],
|
||||
|
||||
"@orama/orama": ["@orama/orama@3.1.18", "", {}, "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA=="],
|
||||
|
||||
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
|
||||
|
||||
"@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
|
||||
|
||||
"@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="],
|
||||
|
||||
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
|
||||
|
||||
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
|
||||
|
||||
"@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
|
||||
|
||||
"@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="],
|
||||
|
||||
"@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="],
|
||||
|
||||
"@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="],
|
||||
|
||||
"@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="],
|
||||
|
||||
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
|
||||
|
||||
"@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="],
|
||||
|
||||
"@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="],
|
||||
|
||||
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
|
||||
|
||||
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
|
||||
|
||||
"@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
|
||||
|
||||
"@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="],
|
||||
|
||||
"@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="],
|
||||
|
||||
"@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="],
|
||||
|
||||
"@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="],
|
||||
|
||||
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
|
||||
|
||||
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
|
||||
|
||||
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
|
||||
|
||||
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
|
||||
|
||||
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
|
||||
|
||||
"@shikijs/core": ["@shikijs/core@4.1.0", "", { "dependencies": { "@shikijs/primitive": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-jLJtSJeuFffqX6/inRE1zqU5aFv2hrszvYgq3OjbAgFRZiWv7abKMDdQzYxuSDfmUPQozZvI/kuy6VMTvnvqTQ=="],
|
||||
|
||||
"@shikijs/engine-javascript": ["@shikijs/engine-javascript@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.6" } }, "sha512-YquhawCUgaBfhsS72e2Y/dI59gCBNPHu3fEO/tvLaXrTssxZrY5ddjtNLTwndrMgPo8b3IscE+xoICDzpTmlFQ=="],
|
||||
|
||||
"@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-axLpjVs45YBvvINa+dJF+NPW+KtFkNXsFr4SDw2BMj9GdeMnGxVB9PQb2xXlJYovslt/nz6giedAyOANkfc7hg=="],
|
||||
|
||||
"@shikijs/langs": ["@shikijs/langs@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-nwOMruEkbgdZfQ/b8CgpNBVOpvG1k0N5tbmgiFeqsan401+x3ILqlzZJowSla4Agmq4hG2Uf2wh5jLTEhR8VSg=="],
|
||||
|
||||
"@shikijs/primitive": ["@shikijs/primitive@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zx2/2Uwj2q9X3KSyYREEhXO23xBw5WUhP4orK2lE4r+t9JGITmEe0JH+wPmJhqHpOT2bRRs6lAL945+LDvOAGw=="],
|
||||
|
||||
"@shikijs/themes": ["@shikijs/themes@4.1.0", "", { "dependencies": { "@shikijs/types": "4.1.0" } }, "sha512-emCcTnUM7yO2wltYbaxm+yLvcCI4+h8XBKc4KmJ7EZUXoSGjcCHifkI//R4OFit9ewpg7H2/9tjOuXrT2v/Knw=="],
|
||||
|
||||
"@shikijs/types": ["@shikijs/types@4.1.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-3EQWX54fMpniOrDblzAhiwiJwpiTMW6+B9DWyUd9ska483tbayFYuw47UxwuPknI31bKnySfVQ/QW+jFL4rFdA=="],
|
||||
|
||||
"@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.0" } }, "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.0", "@tailwindcss/oxide-darwin-arm64": "4.3.0", "@tailwindcss/oxide-darwin-x64": "4.3.0", "@tailwindcss/oxide-freebsd-x64": "4.3.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", "@tailwindcss/oxide-linux-x64-musl": "4.3.0", "@tailwindcss/oxide-wasm32-wasi": "4.3.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.0", "", { "os": "android", "cpu": "arm64" }, "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.0", "", { "dependencies": { "@emnapi/core": "^1.10.0", "@emnapi/runtime": "^1.10.0", "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA=="],
|
||||
|
||||
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.3.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.3.0", "@tailwindcss/oxide": "4.3.0", "postcss": "^8.5.10", "tailwindcss": "4.3.0" } }, "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w=="],
|
||||
|
||||
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||
|
||||
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
|
||||
|
||||
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
|
||||
|
||||
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
|
||||
|
||||
"@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
|
||||
|
||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.1", "", {}, "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ=="],
|
||||
|
||||
"@zeit/schemas": ["@zeit/schemas@2.36.0", "", {}, "sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||
|
||||
"ajv": ["ajv@8.18.0", "", { "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-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
|
||||
|
||||
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
|
||||
"arch": ["arch@2.2.0", "", {}, "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ=="],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
|
||||
|
||||
"astring": ["astring@1.9.0", "", { "bin": { "astring": "bin/astring" } }, "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg=="],
|
||||
|
||||
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.33", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw=="],
|
||||
|
||||
"boxen": ["boxen@7.0.0", "", { "dependencies": { "ansi-align": "^3.0.1", "camelcase": "^7.0.0", "chalk": "^5.0.1", "cli-boxes": "^3.0.0", "string-width": "^5.1.2", "type-fest": "^2.13.0", "widest-line": "^4.0.1", "wrap-ansi": "^8.0.1" } }, "sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"camelcase": ["camelcase@7.0.1", "", {}, "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001793", "", {}, "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA=="],
|
||||
|
||||
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
|
||||
|
||||
"chalk": ["chalk@5.0.1", "", {}, "sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w=="],
|
||||
|
||||
"chalk-template": ["chalk-template@0.4.0", "", { "dependencies": { "chalk": "^4.1.2" } }, "sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg=="],
|
||||
|
||||
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||
|
||||
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
|
||||
|
||||
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||
|
||||
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="],
|
||||
|
||||
"cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="],
|
||||
|
||||
"client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="],
|
||||
|
||||
"clipboardy": ["clipboardy@3.0.0", "", { "dependencies": { "arch": "^2.2.0", "execa": "^5.1.1", "is-wsl": "^2.2.0" } }, "sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
|
||||
|
||||
"compressible": ["compressible@2.0.18", "", { "dependencies": { "mime-db": ">= 1.43.0 < 2" } }, "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg=="],
|
||||
|
||||
"compression": ["compression@1.8.1", "", { "dependencies": { "bytes": "3.1.2", "compressible": "~2.0.18", "debug": "2.6.9", "negotiator": "~0.6.4", "on-headers": "~1.1.0", "safe-buffer": "5.2.1", "vary": "~1.1.2" } }, "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w=="],
|
||||
|
||||
"compute-scroll-into-view": ["compute-scroll-into-view@3.1.1", "", {}, "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw=="],
|
||||
|
||||
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.2", "", {}, "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
|
||||
|
||||
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
|
||||
|
||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
|
||||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.22.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww=="],
|
||||
|
||||
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"esast-util-from-estree": ["esast-util-from-estree@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "unist-util-position-from-estree": "^2.0.0" } }, "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ=="],
|
||||
|
||||
"esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="],
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"estree-util-attach-comments": ["estree-util-attach-comments@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw=="],
|
||||
|
||||
"estree-util-build-jsx": ["estree-util-build-jsx@3.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-walker": "^3.0.0" } }, "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ=="],
|
||||
|
||||
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
|
||||
|
||||
"estree-util-scope": ["estree-util-scope@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0" } }, "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ=="],
|
||||
|
||||
"estree-util-to-js": ["estree-util-to-js@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "astring": "^1.8.0", "source-map": "^0.7.0" } }, "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg=="],
|
||||
|
||||
"estree-util-value-to-estree": ["estree-util-value-to-estree@3.5.0", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ=="],
|
||||
|
||||
"estree-util-visit": ["estree-util-visit@2.0.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/unist": "^3.0.0" } }, "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.40.0", "", { "dependencies": { "motion-dom": "^12.40.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg=="],
|
||||
|
||||
"fumadocs-core": ["fumadocs-core@16.9.3", "", { "dependencies": { "@orama/orama": "^3.1.18", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.1.0", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "flexsearch": "*", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "*", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "flexsearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-8RVzKnzBJR5o+tJCccY28ntekfMQYBoYiz7alnYb/d9YJc+XpnsINzTl63lQ1eBMZ9gdhm2MqRtgUjh/8rUrbw=="],
|
||||
|
||||
"fumadocs-mdx": ["fumadocs-mdx@15.0.10", "", { "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "picocolors": "^1.1.1", "picomatch": "^4.0.4", "tinyexec": "^1.2.2", "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^16.7.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", "react": "^19.2.0", "rolldown": "*", "vite": "7.x.x || 8.x.x" }, "optionalPeers": ["@types/mdast", "@types/mdx", "@types/react", "mdast-util-directive", "next", "react", "rolldown", "vite"], "bin": { "fumadocs-mdx": "./bin.js" } }, "sha512-kH3S7ESS9yXTAaCkA8dDugsCK/MbnpgyZ5qBEL7cWoavV0O/T4+4YTYFkvNknz7cw+T/r+OG0p2BvlVhkk4fww=="],
|
||||
|
||||
"fumadocs-ui": ["fumadocs-ui@16.9.3", "", { "dependencies": { "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-direction": "^1.1.1", "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-presence": "^1.1.5", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", "lucide-react": "^1.17.0", "motion": "^12.40.0", "next-themes": "^0.4.6", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^4.1.0", "tailwind-merge": "^3.6.0", "unist-util-visit": "^5.1.0" }, "peerDependencies": { "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "16.9.3", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@takumi-rs/image-response", "@types/mdx", "@types/react", "next"] }, "sha512-eoVKj1H+ATut0su+WIoPWBLRqzPMGD0hekIBr4GopWvUg1lS997HL4kP+Leyf+3CYlZtFgyXb6ylbvRLFtEj6Q=="],
|
||||
|
||||
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
|
||||
|
||||
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="],
|
||||
|
||||
"hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="],
|
||||
|
||||
"hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
|
||||
|
||||
"hast-util-to-estree": ["hast-util-to-estree@3.1.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="],
|
||||
|
||||
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
|
||||
|
||||
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
|
||||
|
||||
"hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="],
|
||||
|
||||
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
|
||||
|
||||
"hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="],
|
||||
|
||||
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
|
||||
|
||||
"human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||
|
||||
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
|
||||
|
||||
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
|
||||
|
||||
"is-port-reachable": ["is-port-reachable@4.0.0", "", {}, "sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig=="],
|
||||
|
||||
"is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.2.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
|
||||
|
||||
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"markdown-extensions": ["markdown-extensions@2.0.0", "", {}, "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q=="],
|
||||
|
||||
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
|
||||
|
||||
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
|
||||
|
||||
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
|
||||
|
||||
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
|
||||
|
||||
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
|
||||
|
||||
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
|
||||
|
||||
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
|
||||
|
||||
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
|
||||
|
||||
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
|
||||
|
||||
"mdast-util-mdx": ["mdast-util-mdx@3.0.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w=="],
|
||||
|
||||
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
|
||||
|
||||
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
|
||||
|
||||
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
|
||||
|
||||
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
|
||||
|
||||
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
|
||||
|
||||
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
|
||||
|
||||
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
|
||||
|
||||
"merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
|
||||
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
|
||||
|
||||
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
|
||||
|
||||
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
|
||||
|
||||
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
|
||||
|
||||
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
|
||||
|
||||
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
|
||||
|
||||
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
|
||||
|
||||
"micromark-extension-mdx-expression": ["micromark-extension-mdx-expression@3.0.1", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q=="],
|
||||
|
||||
"micromark-extension-mdx-jsx": ["micromark-extension-mdx-jsx@3.0.2", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "micromark-factory-mdx-expression": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ=="],
|
||||
|
||||
"micromark-extension-mdx-md": ["micromark-extension-mdx-md@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ=="],
|
||||
|
||||
"micromark-extension-mdxjs": ["micromark-extension-mdxjs@3.0.0", "", { "dependencies": { "acorn": "^8.0.0", "acorn-jsx": "^5.0.0", "micromark-extension-mdx-expression": "^3.0.0", "micromark-extension-mdx-jsx": "^3.0.0", "micromark-extension-mdx-md": "^2.0.0", "micromark-extension-mdxjs-esm": "^3.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ=="],
|
||||
|
||||
"micromark-extension-mdxjs-esm": ["micromark-extension-mdxjs-esm@3.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A=="],
|
||||
|
||||
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
|
||||
|
||||
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
|
||||
|
||||
"micromark-factory-mdx-expression": ["micromark-factory-mdx-expression@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-events-to-acorn": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-position-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ=="],
|
||||
|
||||
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
|
||||
|
||||
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
|
||||
|
||||
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
|
||||
|
||||
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
|
||||
|
||||
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
|
||||
|
||||
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
|
||||
|
||||
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
|
||||
|
||||
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
|
||||
|
||||
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
|
||||
|
||||
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
|
||||
|
||||
"micromark-util-events-to-acorn": ["micromark-util-events-to-acorn@2.0.3", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "estree-util-visit": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg=="],
|
||||
|
||||
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
|
||||
|
||||
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
|
||||
|
||||
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
|
||||
|
||||
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
|
||||
|
||||
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
|
||||
|
||||
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
|
||||
|
||||
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.18", "", { "dependencies": { "mime-db": "~1.33.0" } }, "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ=="],
|
||||
|
||||
"mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="],
|
||||
|
||||
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||
|
||||
"motion": ["motion@12.40.0", "", { "dependencies": { "framer-motion": "^12.40.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.40.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="],
|
||||
|
||||
"next": ["next@16.2.6", "", { "dependencies": { "@next/env": "16.2.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.6", "@next/swc-darwin-x64": "16.2.6", "@next/swc-linux-arm64-gnu": "16.2.6", "@next/swc-linux-arm64-musl": "16.2.6", "@next/swc-linux-x64-gnu": "16.2.6", "@next/swc-linux-x64-musl": "16.2.6", "@next/swc-win32-arm64-msvc": "16.2.6", "@next/swc-win32-x64-msvc": "16.2.6", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw=="],
|
||||
|
||||
"next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="],
|
||||
|
||||
"npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="],
|
||||
|
||||
"on-headers": ["on-headers@1.1.0", "", {}, "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A=="],
|
||||
|
||||
"onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="],
|
||||
|
||||
"oniguruma-parser": ["oniguruma-parser@0.12.2", "", {}, "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw=="],
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"path-is-inside": ["path-is-inside@1.0.2", "", {}, "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@3.3.0", "", {}, "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
|
||||
|
||||
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.0", "", {}, "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A=="],
|
||||
|
||||
"rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="],
|
||||
|
||||
"react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="],
|
||||
|
||||
"react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="],
|
||||
|
||||
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
|
||||
|
||||
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
|
||||
|
||||
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
|
||||
|
||||
"recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="],
|
||||
|
||||
"recma-parse": ["recma-parse@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "esast-util-from-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ=="],
|
||||
|
||||
"recma-stringify": ["recma-stringify@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-to-js": "^2.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g=="],
|
||||
|
||||
"regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="],
|
||||
|
||||
"regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="],
|
||||
|
||||
"regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="],
|
||||
|
||||
"registry-auth-token": ["registry-auth-token@3.3.2", "", { "dependencies": { "rc": "^1.1.6", "safe-buffer": "^5.0.1" } }, "sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ=="],
|
||||
|
||||
"registry-url": ["registry-url@3.1.0", "", { "dependencies": { "rc": "^1.0.1" } }, "sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA=="],
|
||||
|
||||
"rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="],
|
||||
|
||||
"rehype-recma": ["rehype-recma@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="],
|
||||
|
||||
"remark": ["remark@15.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="],
|
||||
|
||||
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
|
||||
|
||||
"remark-mdx": ["remark-mdx@3.1.1", "", { "dependencies": { "mdast-util-mdx": "^3.0.0", "micromark-extension-mdxjs": "^3.0.0" } }, "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg=="],
|
||||
|
||||
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
|
||||
|
||||
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
|
||||
|
||||
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"scroll-into-view-if-needed": ["scroll-into-view-if-needed@3.1.0", "", { "dependencies": { "compute-scroll-into-view": "^3.0.2" } }, "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ=="],
|
||||
|
||||
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
|
||||
"serve": ["serve@14.2.6", "", { "dependencies": { "@zeit/schemas": "2.36.0", "ajv": "8.18.0", "arg": "5.0.2", "boxen": "7.0.0", "chalk": "5.0.1", "chalk-template": "0.4.0", "clipboardy": "3.0.0", "compression": "1.8.1", "is-port-reachable": "4.0.0", "serve-handler": "6.1.7", "update-check": "1.5.4" }, "bin": { "serve": "build/main.js" } }, "sha512-QEjUSA+sD4Rotm1znR8s50YqA3kYpRGPmtd5GlFxbaL9n/FdUNbqMhxClqdditSk0LlZyA/dhud6XNRTOC9x2Q=="],
|
||||
|
||||
"serve-handler": ["serve-handler@6.1.7", "", { "dependencies": { "bytes": "3.0.0", "content-disposition": "0.5.2", "mime-types": "2.1.18", "minimatch": "3.1.5", "path-is-inside": "1.0.2", "path-to-regexp": "3.3.0", "range-parser": "1.2.0" } }, "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shiki": ["shiki@4.1.0", "", { "dependencies": { "@shikijs/core": "4.1.0", "@shikijs/engine-javascript": "4.1.0", "@shikijs/engine-oniguruma": "4.1.0", "@shikijs/langs": "4.1.0", "@shikijs/themes": "4.1.0", "@shikijs/types": "4.1.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-l/ABZPUR5v70jI10EzqfMS/I96vjSGv2y0ihUV+WYFzv0EfvW4s54m0Lg8wCrrL+2IkwBzFTuxkZjPf8b2NX9Q=="],
|
||||
|
||||
"signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
|
||||
|
||||
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="],
|
||||
|
||||
"strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
|
||||
|
||||
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
|
||||
|
||||
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.0", "", {}, "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
|
||||
|
||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="],
|
||||
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
|
||||
|
||||
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
|
||||
|
||||
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
|
||||
|
||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||
|
||||
"unist-util-position-from-estree": ["unist-util-position-from-estree@2.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ=="],
|
||||
|
||||
"unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="],
|
||||
|
||||
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
|
||||
|
||||
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
|
||||
|
||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||
|
||||
"update-check": ["update-check@1.5.4", "", { "dependencies": { "registry-auth-token": "3.3.2", "registry-url": "3.1.0" } }, "sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ=="],
|
||||
|
||||
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
|
||||
|
||||
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
"vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="],
|
||||
|
||||
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
|
||||
|
||||
"web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"widest-line": ["widest-line@4.0.1", "", { "dependencies": { "string-width": "^5.0.1" } }, "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
|
||||
|
||||
"zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
|
||||
|
||||
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"chalk-template/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"micromark/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"mime-types/mime-db": ["mime-db@1.33.0", "", {}, "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ=="],
|
||||
|
||||
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"serve-handler/bytes": ["bytes@3.0.0", "", {}, "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw=="],
|
||||
|
||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"chalk-template/chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"micromark/debug/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
# 前后端接口响应约定
|
||||
---
|
||||
title: 接口响应约定
|
||||
description: 业务接口统一响应结构与前端处理约定
|
||||
---
|
||||
|
||||
# 接口响应约定
|
||||
|
||||
后端业务接口统一返回 JSON:
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
---
|
||||
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` | 后端模型接口调用失败返还 |
|
||||
+5
-65
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 画布数据结构
|
||||
description: 画布本地存储、节点结构、媒体文件与清理机制
|
||||
---
|
||||
|
||||
# 画布数据结构
|
||||
|
||||
本文档说明当前画布在前端本地保存的数据结构、图片文件的存储和清理方式,以及后续接入后端存储时建议保持的兼容边界。
|
||||
@@ -217,68 +222,3 @@ type UploadedImage = {
|
||||
5. 删除时会同时 `URL.revokeObjectURL`,并从内存缓存 `objectUrls` 移除。
|
||||
|
||||
这套方式可以避免同一张图片被画布、素材或助手同时引用时误删。
|
||||
|
||||
需要注意:
|
||||
|
||||
- 只要某个 JSON 结构里仍有 `storageKey`,清理逻辑就会认为图片仍被使用。
|
||||
- `collectImageStorageKeys` 会递归扫描对象中的 `storageKey` 字段,字段值必须以 `image:` 开头才会被当成本地图片。
|
||||
- 如果后续新增保存图片引用的数据结构,也要确保它能传入清理上下文,或者位于现有项目/素材结构内。
|
||||
|
||||
## 后端存储兼容建议
|
||||
|
||||
后续接入后端时,建议保持“画布 JSON”和“图片文件”分离:
|
||||
|
||||
- 画布表保存项目元信息和画布 JSON。
|
||||
- 文件表保存图片文件、访问 URL、哈希、大小、MIME、宽高、归属用户等信息。
|
||||
- 画布节点中继续保存轻量图片引用,不把图片二进制或 base64 写进画布 JSON。
|
||||
|
||||
建议图片引用逐步扩展为兼容本地和云端的结构:
|
||||
|
||||
```ts
|
||||
type ImageRef = {
|
||||
storageKey?: string;
|
||||
fileId?: string;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bytes?: number;
|
||||
mimeType?: string;
|
||||
};
|
||||
```
|
||||
|
||||
兼容规则:
|
||||
|
||||
- 本地旧数据:有 `storageKey`,无 `fileId`,通过 IndexedDB 读取。
|
||||
- 已上传后端:有 `fileId`,展示时优先使用后端返回的签名 URL 或公开 URL。
|
||||
- 迁移过渡期:可以同时保留 `storageKey` 和 `fileId`;确认云端文件可用后,再按清理策略删除本地 Blob。
|
||||
- `content/dataUrl/coverUrl` 仍只作为当前可展示 URL,不作为稳定 ID。
|
||||
|
||||
建议读取优先级:
|
||||
|
||||
1. 有 `fileId`:向后端换取可访问 URL。
|
||||
2. 有 `storageKey`:从本地 IndexedDB 生成 `blob:` URL。
|
||||
3. 有旧 `data:image/...`:先写入本地图片存储,再视需要上传后端。
|
||||
4. 只有普通 URL:直接展示,但不要假设可长期访问。
|
||||
|
||||
建议删除策略:
|
||||
|
||||
- 删除节点只删除画布 JSON 引用,不直接删除后端文件。
|
||||
- 后端文件删除应按引用计数或定期扫描未引用文件处理。
|
||||
- 保存到“我的素材”的图片,即使原画布节点删除,也应继续保留文件引用。
|
||||
- 删除画布、删除素材、删除助手会话后,再由后端清理任务判断文件是否无人引用。
|
||||
|
||||
建议同步流程:
|
||||
|
||||
1. 前端保存画布 JSON 时,保持节点 ID、连线 ID、`storageKey/fileId` 不变。
|
||||
2. 遇到只有 `storageKey` 的图片,后台同步前先上传 Blob,得到 `fileId`。
|
||||
3. 上传成功后给对应图片引用补 `fileId` 和云端元信息。
|
||||
4. 服务端保存更新后的画布 JSON。
|
||||
5. 前端下次打开时优先走 `fileId`,本地 `storageKey` 只作为缓存或离线回退。
|
||||
|
||||
## 后续改动约束
|
||||
|
||||
- 不要把新生成的大图直接长期写入画布 JSON。
|
||||
- 新增图片来源时统一走 `uploadImage` 或未来的文件上传服务。
|
||||
- 新增图片引用字段时,应保留 `storageKey` 兼容旧本地数据。
|
||||
- 新增清理入口时,要把仍需保留的画布、素材、助手数据传给 `cleanupUnusedImages`。
|
||||
- 后端同步完成前,文档和 UI 不要写成已支持云同步。
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: 本地开发
|
||||
description: 前后端分开启动时的本地开发方式
|
||||
---
|
||||
|
||||
# 本地开发
|
||||
|
||||
如果你需要改代码,建议前后端分开启动。
|
||||
|
||||
## 1. 准备环境变量
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
默认配置下:
|
||||
|
||||
- 后端端口是 `8080`
|
||||
- 前端端口是 `3000`
|
||||
- SQLite 数据库是 `data/infinite-canvas.db`
|
||||
|
||||
## 2. 启动后端
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
go run .
|
||||
```
|
||||
|
||||
后端会读取根目录 `.env`,并监听:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
## 3. 启动前端
|
||||
|
||||
在 `web` 目录执行:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
前端默认访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
开发代理默认转发到 `http://127.0.0.1:8080`。如果你的后端端口不同,启动前设置 `API_BASE_URL`。
|
||||
|
||||
## 4. 启动文档站
|
||||
|
||||
如果需要单独调整文档站,在 `docs` 目录执行:
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## 常见场景
|
||||
|
||||
- 改画布、页面和交互:主要看 `web/`
|
||||
- 改接口、业务逻辑和数据库:主要看仓库根目录下的 Go 代码
|
||||
- 改文档站内容:主要看 `docs/content/docs/`
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "开发文档",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"local-development",
|
||||
"api-response",
|
||||
"system-settings",
|
||||
"backend-database",
|
||||
"canvas-data-structure"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 系统配置数据结构
|
||||
description: settings 表中 public 和 private 配置结构说明
|
||||
---
|
||||
|
||||
# 系统配置数据结构
|
||||
|
||||
系统配置保存在 `settings` 表中,目前只使用两行:
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
title: 商务合作
|
||||
description: 商务合作、定制开发和项目接入说明
|
||||
---
|
||||
|
||||
# 商务合作
|
||||
|
||||
如果你希望围绕无限画布做商业合作、私有化部署、二次开发或模型渠道接入,可以直接通过邮箱联系项目维护者。
|
||||
|
||||
## 适合沟通的方向
|
||||
|
||||
- 私有化部署、内网部署和团队版落地。
|
||||
- 定制画布节点、生成流程、素材管理或后台能力。
|
||||
- 接入自有 OpenAI 兼容接口、模型渠道或提示词仓库。
|
||||
- 围绕 AI 图片、视频、文本生成工作流做联合方案。
|
||||
- 商业授权、技术支持、交付咨询或联合方案评估。
|
||||
|
||||
## 建议提供的信息
|
||||
|
||||
- 你的业务场景和预期用户规模。
|
||||
- 需要部署的环境、数据保存方式和安全要求。
|
||||
- 需要接入的模型、渠道、支付或账号体系。
|
||||
- 期望的交付范围、时间和预算区间。
|
||||
|
||||
## 联系方式
|
||||
|
||||
请直接发送邮件至 [1844025705@qq.com](mailto:1844025705@qq.com),并在邮件标题中注明“商务合作”。
|
||||
|
||||
如果涉及商业授权或私有化交付,请在邮件中说明计划使用方式和预期交付范围。
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
title: 开源协议
|
||||
description: 无限画布开源协议和使用边界说明
|
||||
---
|
||||
|
||||
# 开源协议
|
||||
|
||||
无限画布采用 GNU Affero General Public License v3.0(AGPL-3.0)开源协议。你可以在遵守协议要求的前提下自由使用、复制、修改和分发本项目。
|
||||
|
||||
## 项目初衷
|
||||
|
||||
无限画布保持开源,希望让更多人可以查看和修改代码,不被固定 API 渠道限制,接入自己的 API 与模型渠道,用画布组织提示词、参考图、素材和生成结果,搭建适合自己的 AI 创作工作流。
|
||||
|
||||
|
||||
## 使用建议
|
||||
|
||||
如果你只是本地学习、研究或自用,可以直接按开源协议使用。
|
||||
|
||||
如果你计划将项目用于商业产品、私有化交付、SaaS 服务或长期团队内部系统,建议先确认自己的使用方式是否满足 AGPL-3.0 的开源要求。
|
||||
|
||||
如果你的使用场景无法继续开源修改后的代码,或需要闭源交付、商业授权、私有化部署支持、定制开发服务,请查看[商务合作](/docs/business/business)。
|
||||
|
||||
|
||||
|
||||
## 什么是 AGPL-3.0
|
||||
|
||||
AGPL-3.0 的核心要求可以通俗理解为:
|
||||
|
||||
- 开源义务:如果你使用了无限画布的 AGPL 代码,无论你或你的下游如何使用、修改、二次开发或分发,都必须把基于本项目形成的最终代码完整公开出来,不只是公开修改的部分,也不是换个框架重写一遍就能和原始代码脱离关系。
|
||||
- 延续协议:基于本项目形成的衍生代码需要继续以 AGPL-3.0 协议开源,不能更换为其他协议,也不能把这部分代码改成闭源授权。
|
||||
- 网络服务也要开源:即使你只是把基于本项目修改后的版本做成网站、SaaS 或其他网络服务,只要别人可以通过网络使用这个服务,也需要向这些用户提供对应源代码,并继续遵守 AGPL-3.0。
|
||||
- 保留版权声明:不能删除原项目作者信息、版权声明、许可证声明和来源说明,需要让使用者知道代码从哪里来、遵循什么协议。
|
||||
- 不能加额外限制:不能给基于本项目形成的 AGPL 代码增加额外限制,例如禁止别人再分发代码,或要求别人购买授权、服务、产品后才能使用这部分开源代码。
|
||||
- 免责声明:项目按现状提供,作者不保证代码没有 bug,也不对使用本项目产生的结果、损失或风险负责。
|
||||
|
||||
本项目禁止闭源商用。如果你希望将无限画布用于商业项目,请尊重开源,严格遵循 AGPL-3.0 协议,继续开源对应代码,回馈开源社区。
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "商务合作",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"license",
|
||||
"business"
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 画布节点操作手册
|
||||
description: 当前画布节点的主要用途与操作流程
|
||||
---
|
||||
|
||||
# 画布节点操作手册
|
||||
|
||||
本文档记录画布节点的主要用途和操作流程,方便使用和后续开发维护。当前先介绍文本节点。
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 画布快捷键
|
||||
description: 画布常用鼠标与键盘操作说明
|
||||
---
|
||||
|
||||
# 画布快捷键
|
||||
|
||||
本文档记录画布里常用的鼠标和键盘操作。
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "操作手册",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"canvas-node-manual",
|
||||
"canvas-shortcuts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "文档",
|
||||
"root": true,
|
||||
"pages": [
|
||||
"overview",
|
||||
"canvas",
|
||||
"backend",
|
||||
"progress",
|
||||
"business",
|
||||
"support"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Docker 部署
|
||||
description: 使用 Docker Compose 部署无限画布
|
||||
---
|
||||
|
||||
# Docker 部署
|
||||
|
||||
如果你希望在自己的机器或服务器上运行项目,可以直接使用 Docker Compose。
|
||||
|
||||
## 使用发布镜像
|
||||
|
||||
```bash
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认管理员账号:
|
||||
|
||||
```text
|
||||
用户名:admin
|
||||
密码:.env 中的 ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## 本地构建镜像
|
||||
|
||||
如果需要基于当前源码构建镜像:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## 文档站镜像
|
||||
|
||||
文档站位于 `docs/` 目录,按带服务端能力的 Next.js standalone 应用单独构建,不打进主应用镜像。
|
||||
|
||||
使用发布镜像:
|
||||
|
||||
```bash
|
||||
cd docs
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
基于当前源码本地构建:
|
||||
|
||||
```bash
|
||||
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` 设置为公网可访问的站点地址。
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 功能介绍
|
||||
description: 当前项目已实现的主要功能
|
||||
---
|
||||
|
||||
# 功能介绍
|
||||
|
||||
本文档记录当前项目已经实现的主要功能。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"title": "项目介绍",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"quick-start",
|
||||
"features",
|
||||
"render",
|
||||
"docker",
|
||||
"third-party-prompt-repositories",
|
||||
"[在线体验](https://infinite-canvas-cpco.onrender.com/)"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
title: 快速开始
|
||||
description: 用最少步骤把无限画布跑起来
|
||||
---
|
||||
|
||||
# 快速开始
|
||||
|
||||
如果你只是想先把项目跑起来,优先使用 Docker。
|
||||
|
||||
## Docker 启动
|
||||
|
||||
```bash
|
||||
git clone git@github.com:basketikun/infinite-canvas.git
|
||||
cd infinite-canvas
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
启动后访问:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
```
|
||||
|
||||
默认管理员账号:
|
||||
|
||||
```text
|
||||
用户名:admin
|
||||
密码:.env 中的 ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
## 本地构建镜像启动
|
||||
|
||||
如果你需要基于当前源码本地构建镜像:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker compose -f docker-compose.local.yml up -d --build
|
||||
```
|
||||
|
||||
## 首次使用建议
|
||||
|
||||
- 先打开右上角配置弹窗,填入自己的 `Base URL`、`API Key` 和模型名。
|
||||
- 如果使用后台渠道模式,再去管理后台补充系统模型与渠道配置。
|
||||
- 如果需要提示词仓库内容,可进入 `/admin/prompts` 拉取或同步。
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不支持云同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
@@ -1,8 +1,13 @@
|
||||
---
|
||||
title: Render 部署
|
||||
description: 使用 Render 部署无限画布
|
||||
---
|
||||
|
||||
# Render 部署
|
||||
|
||||
点击下面按钮即可部署到 Render:
|
||||
点击下面链接即可部署到 Render:
|
||||
|
||||
[](https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas)
|
||||
[部署到 Render](https://render.com/deploy?repo=https://github.com/basketikun/infinite-canvas)
|
||||
|
||||
## 部署步骤
|
||||
|
||||
+5
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: 第三方 GitHub 提示词仓库
|
||||
description: 当前已接入同步逻辑的第三方提示词仓库
|
||||
---
|
||||
|
||||
# 第三方 GitHub 提示词仓库
|
||||
|
||||
| 地址 | 状态 |
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"title": "项目进度",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"[更新日志](/docs/progress/changelog)",
|
||||
"pending-test",
|
||||
"todo"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,26 @@
|
||||
---
|
||||
title: 待测试
|
||||
description: 当前版本已实现但仍需人工验证的变更项
|
||||
---
|
||||
|
||||
# 待测试
|
||||
|
||||
- 视频创作台创建视频任务成功后会立即写入左侧生成记录,状态显示为“生成中”;刷新页面后会读取本地任务 ID 继续轮询,任务成功或失败后更新同一条记录,需要验证 OpenAI 视频接口和 Seedance 任务接口的刷新恢复。
|
||||
- 修复画布文字节点编辑时双击进入编辑、全选文本出现重影和错位的问题;文字节点内容编辑框改为使用原生 textarea 文本渲染,仍保留 `@` 资源候选插入,需要验证编辑态选区、展示态文字换行和右上角“生图”按钮避让都正常。
|
||||
- 配置弹窗新增 WebDAV 同步配置,可填写 WebDAV 地址、远程目录、用户名和密码/应用密码,并选择“前端直连”或“Next.js 转发”;远端会按 `canvas/`、`assets/`、`image-workbench/`、`video-workbench/` 四个业务目录分别写入 `manifest.json` 清单和 `files/` 媒体目录,会合并画布项目、我的素材、生图/视频生成记录和引用到的本地媒体文件,四个业务分区会并发同步,并在同步中显示读取远端、检查媒体、上传新增媒体、上传清单等阶段和文件计数进度条;WebDAV 请求会做超时提示,目录已存在但 `MKCOL` 返回 `423 Locked` 时会复查目录存在后继续同步,需要在支持 CORS 的 NAS/WebDAV 服务和不支持 CORS 的 Koofr 转发模式下验证首次上传、第二台设备拉取合并、再次同步和认证失败提示。
|
||||
- 图片节点悬浮工具栏新增“切图”入口;点击后可输入行数和列数,预览切分网格,并在确认后把原图切成对应数量的图片子节点,按原图网格排列到画布右侧且自动与原图连线。
|
||||
- 画布生成配置节点的“生成配置/组装提示词”改为在节点下方打开独立输入浮层;浮层支持输入 `@` 从已连接图片、文本、视频、音频中选择引用,引用会以图片缩略图或文本标记展示,图片引用可放大预览;生成时再按当前引用解析为实际素材编号,不再提供或读取输入排序。
|
||||
- 图片节点悬浮工具栏新增“局部编辑”入口,打开后可在图片上用画笔/擦除工具绘制遮罩区域、填写局部修改要求,并通过图片编辑接口携带同尺寸 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`。
|
||||
@@ -14,6 +35,8 @@
|
||||
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
||||
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
||||
- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。
|
||||
- 生图工作台生成记录会过滤空缩略图地址,避免历史记录卡片渲染 `src=""` 图片。
|
||||
- 图像设置面板新增默认开启的“16倍数对齐”尺寸 toggle;开启时手动输入宽高并失焦、回车或点击浮层外关闭后,会把非 16 倍数的宽高向上补齐。
|
||||
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
||||
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
|
||||
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
title: TODO
|
||||
description: 当前项目后续值得处理的事项
|
||||
---
|
||||
|
||||
# TODO
|
||||
|
||||
本文档用来记录当前项目后续比较值得处理的事项。
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
title: 打赏支持
|
||||
description: 支持无限画布项目继续维护
|
||||
---
|
||||
|
||||
# 打赏支持
|
||||
|
||||
如果本项目对你有帮助,欢迎通过打赏、Star、提供AI订阅账号等方式支持项目继续维护。
|
||||
|
||||
项目开发大量依赖 Codex、Claude 等 AI 编程工具辅助写代码、排查问题和整理文档,这些工具需要持续付费。你的打赏会优先用于购买 AI 开发工具、模型服务和项目维护相关资源,帮助项目继续迭代。
|
||||
|
||||
## 支持方式
|
||||
|
||||
支持方式包括但不限于:
|
||||
|
||||
- 给 GitHub 仓库点 Star,帮助项目被更多人看到。
|
||||
- 金额打赏,用于支持 AI 工具订阅、模型服务和项目维护成本。
|
||||
- Codex、Claude 等 AI 编程工具订阅账号支持,帮助维护者持续使用 AI 辅助开发。
|
||||
|
||||
<div className="not-prose mt-6 flex flex-wrap items-center gap-3">
|
||||
<span className="text-lg font-semibold text-fd-foreground">赞助地址:</span>
|
||||
<a href="https://ifdian.net/a/basketikun" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<img src="https://img.shields.io/badge/%E7%88%B1%E5%8F%91%E7%94%B5-%E8%B5%9E%E5%8A%A9%E4%BD%9C%E8%80%85-946ce6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0id2hpdGUiPjxwYXRoIGQ9Ik0xMiAyMS4zNWwtMS40NS0xLjMyQzUuNCAxNS4zNiAyIDEyLjI4IDIgOC41IDIgNS40MiA0LjQyIDMgNy41IDNjMS43NCAwIDMuNDEuODEgNC41IDIuMDlDMTMuMDkgMy44MSAxNC43NiAzIDE2LjUgMyAxOS41OCAzIDIyIDUuNDIgMjIgOC41YzAgMy43OC0zLjQgNi44Ni04LjU1IDExLjU0TDEyIDIxLjM1eiIvPjwvc3ZnPg==&logoColor=white" alt="爱发电赞助" />
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "赞助支持",
|
||||
"root": true,
|
||||
"defaultOpen": true,
|
||||
"pages": [
|
||||
"donate",
|
||||
"sponsor"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: 广告赞助
|
||||
description: 广告赞助、项目露出和社区推广说明
|
||||
---
|
||||
|
||||
# 广告赞助
|
||||
|
||||
如果你希望在项目文档、README 或相关页面获得赞助露出,可以直接通过邮箱联系项目维护者沟通位置、周期和展示形式。
|
||||
|
||||
## 适合的赞助内容
|
||||
|
||||
- AI 工具、模型服务、API 服务或开发者工具。
|
||||
- 与图片、视频、文本生成工作流相关的产品。
|
||||
- 适合开源项目用户和 AI 创作者使用的服务。
|
||||
|
||||
## 建议提供的信息
|
||||
|
||||
- 品牌或产品名称。
|
||||
- 希望展示的位置和周期。
|
||||
- 需要展示的链接、文案和素材。
|
||||
- 预算区间和合作方式。
|
||||
|
||||
## 联系方式
|
||||
|
||||
请直接发送邮件至 [1844025705@qq.com](mailto:1844025705@qq.com),并在邮件标题中注明“广告赞助”。
|
||||
|
||||
如果已经有展示素材或投放计划,可以在邮件中一并说明。
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
docs:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docs/Dockerfile
|
||||
ports:
|
||||
- "3001:3000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,6 @@
|
||||
services:
|
||||
docs:
|
||||
image: ghcr.io/basketikun/infinite-canvas-docs:latest
|
||||
ports:
|
||||
- "3001:3000"
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,44 @@
|
||||
# 无限画布文档索引
|
||||
|
||||
## 项目介绍
|
||||
|
||||
- [快速开始](/docs/overview/quick-start)
|
||||
- [功能介绍](/docs/overview/features)
|
||||
- [Render 部署](/docs/overview/render)
|
||||
- [Docker 部署](/docs/overview/docker)
|
||||
- [第三方 GitHub 提示词仓库](/docs/overview/third-party-prompt-repositories)
|
||||
|
||||
## 操作手册
|
||||
|
||||
- [画布节点操作手册](/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/business)
|
||||
|
||||
## 赞助支持
|
||||
|
||||
- [文档](/docs/support/docs)
|
||||
- [打赏支持](/docs/support/donate)
|
||||
- [广告赞助](/docs/support/sponsor)
|
||||
|
||||
## 项目进度
|
||||
|
||||
- [更新日志](/docs/progress/changelog)
|
||||
- [待测试](/docs/progress/pending-test)
|
||||
- [TODO](/docs/progress/todo)
|
||||
|
||||
## 说明
|
||||
|
||||
- 当前画布项目和“我的素材”主要保存在浏览器本地,不支持云同步。
|
||||
- 本地直连模式下,AI API Key 保存在浏览器本地,并由前端直接请求 OpenAI 兼容接口。
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createMDX } from 'fumadocs-mdx/next';
|
||||
|
||||
const withMDX = createMDX();
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const config = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
export default withMDX(config);
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "docs",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
"dev": "next dev",
|
||||
"start": "next start",
|
||||
"types:check": "fumadocs-mdx && next typegen && tsc --noEmit",
|
||||
"postinstall": "fumadocs-mdx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"fumadocs-core": "16.9.3",
|
||||
"fumadocs-mdx": "15.0.10",
|
||||
"fumadocs-ui": "16.9.3",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.6",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/mdx": "^2.0.13",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/react": "^19.2.15",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"postcss": "^8.5.15",
|
||||
"serve": "^14.2.6",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>
|
||||
|
After Width: | Height: | Size: 907 B |
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32 8L58 54H46L32 29L18 54H6L32 8Z" fill="currentColor"/>
|
||||
<path d="M32 40L40 54H24L32 40Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1780302626117" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1702" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M824.8 613.2c-16-51.4-34.4-94.6-62.7-165.3C766.5 262.2 689.3 112 511.5 112 331.7 112 256.2 265.2 261 447.9c-28.4 70.8-46.7 113.7-62.7 165.3-34 109.5-23 154.8-14.6 155.8 18 2.2 70.1-82.4 70.1-82.4 0 49 25.2 112.9 79.8 159-26.4 8.1-85.7 29.9-71.6 53.8 11.4 19.3 196.2 12.3 249.5 6.3 53.3 6 238.1 13 249.5-6.3 14.1-23.8-45.3-45.7-71.6-53.8 54.6-46.2 79.8-110.1 79.8-159 0 0 52.1 84.6 70.1 82.4 8.5-1.1 19.5-46.4-14.5-155.8z" p-id="1703"></path></svg>
|
||||
|
After Width: | Height: | Size: 780 B |
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, defineDocs } from 'fumadocs-mdx/config';
|
||||
import { metaSchema, pageSchema } from 'fumadocs-core/source/schema';
|
||||
|
||||
// You can customize Zod schemas for frontmatter and `meta.json` here
|
||||
// see https://fumadocs.dev/docs/mdx/collections
|
||||
export const docs = defineDocs({
|
||||
dir: 'content/docs',
|
||||
docs: {
|
||||
schema: pageSchema,
|
||||
postprocess: {
|
||||
includeProcessedMarkdown: true,
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
schema: metaSchema,
|
||||
},
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
mdxOptions: {
|
||||
// MDX options
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return <HomeLayout {...baseOptions()}>{children}</HomeLayout>;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import Link from 'next/link';
|
||||
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 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`;
|
||||
|
||||
const previewImages = [
|
||||
{
|
||||
src: 'https://i.ibb.co/TDFvGWDT/image.png',
|
||||
title: '画布编排',
|
||||
},
|
||||
{
|
||||
src: 'https://i.ibb.co/zVwJq3YS/image.png',
|
||||
title: '图片生成',
|
||||
},
|
||||
{
|
||||
src: 'https://i.ibb.co/PvY3qhhK/image.png',
|
||||
title: '参考图编辑',
|
||||
},
|
||||
{
|
||||
src: 'https://i.ibb.co/7D04LwN/image.png',
|
||||
title: '节点工作流',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-5 pb-16 pt-8 md:px-10 md:pt-14">
|
||||
<section className="grid min-h-[520px] items-center gap-10 border-b border-zinc-200 pb-12 dark:border-zinc-800 lg:grid-cols-[0.88fr_1.12fr]">
|
||||
<div>
|
||||
<div className="inline-flex items-center gap-2 text-xs font-medium text-zinc-500 dark:text-zinc-400">
|
||||
<Rocket className="size-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||
开源 AI 图片创作工作台
|
||||
</div>
|
||||
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-zinc-950 dark:text-zinc-50 md:text-6xl [font-family:var(--font-display)]">
|
||||
{appName}
|
||||
<span className="block text-zinc-500 dark:text-zinc-400">文档中心</span>
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
面向图片创作的无限画布,把画布编排、AI 生成、参考图编辑、提示词库和素材沉淀放在同一个工作流里。
|
||||
</p>
|
||||
<div className="mt-8 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/docs/overview/quick-start"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-zinc-950 px-5 py-3 text-sm font-medium text-white transition hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-950 dark:hover:bg-zinc-200"
|
||||
>
|
||||
<BookOpen className="size-4" />
|
||||
快速开始
|
||||
</Link>
|
||||
<a
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-zinc-300 px-5 py-3 text-sm font-medium text-zinc-900 transition hover:border-zinc-900 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-100 dark:hover:border-zinc-500 dark:hover:bg-zinc-900"
|
||||
>
|
||||
<img src="/github.svg" alt="" className="size-4" />
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-zinc-300 px-5 py-3 text-sm font-medium text-zinc-900 transition hover:border-zinc-900 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-100 dark:hover:border-zinc-500 dark:hover:bg-zinc-900"
|
||||
>
|
||||
在线体验
|
||||
<ArrowUpRight className="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl lg:w-[108%] lg:max-w-none">
|
||||
<img
|
||||
src={previewImages[3].src}
|
||||
alt="无限画布效果图"
|
||||
className="aspect-[16/10] w-full rounded-xl object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-14">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-zinc-950 dark:text-zinc-50 md:text-3xl">
|
||||
效果展示
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/docs/overview/features"
|
||||
className="inline-flex w-fit items-center gap-1.5 text-sm font-medium text-zinc-800 transition hover:text-zinc-950 dark:text-zinc-200 dark:hover:text-white"
|
||||
>
|
||||
功能介绍
|
||||
<ArrowUpRight className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{previewImages.map((item) => (
|
||||
<img
|
||||
key={item.src}
|
||||
src={item.src}
|
||||
alt={`${item.title}效果图`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="aspect-[16/10] w-full rounded-2xl object-cover"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto mt-16 w-full max-w-4xl text-center">
|
||||
<h2 className="text-2xl font-semibold text-zinc-950 dark:text-zinc-50 md:text-3xl">
|
||||
开发贡献者
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
感谢所有为本项目做出贡献的开发者
|
||||
</p>
|
||||
<div className="mt-7 flex justify-center">
|
||||
<a
|
||||
href={`${githubUrl}/graphs/contributors`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="inline-flex max-w-full"
|
||||
>
|
||||
<img
|
||||
src={`https://contrib.rocks/image?repo=${gitConfig.user}/${gitConfig.repo}`}
|
||||
alt="开发贡献者头像"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="max-w-full"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mx-auto mt-16 w-full max-w-5xl text-center">
|
||||
<h2 className="text-2xl font-semibold text-zinc-950 dark:text-zinc-50 md:text-3xl">
|
||||
Star History
|
||||
</h2>
|
||||
<div className="mt-7 flex justify-center">
|
||||
<a
|
||||
href={starHistoryUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="block w-full max-w-4xl"
|
||||
>
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcSet={darkStarHistoryChart}
|
||||
/>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcSet={starHistoryChart}
|
||||
/>
|
||||
<img
|
||||
src={starHistoryChart}
|
||||
alt="Star History Chart"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
className="mx-auto w-full"
|
||||
/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createDocsSearchTokenizer } from '@/lib/search-tokenizer';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export const { staticGET: GET } = createFromSource(source, {
|
||||
components: {
|
||||
tokenizer: createDocsSearchTokenizer(),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { DocPageContent, getDocPageMetadata } from '@/lib/doc-page';
|
||||
import { source } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page(props: PageProps<'/docs/[...slug]'>) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return <DocPageContent page={page} />;
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: PageProps<'/docs/[...slug]'>): Promise<Metadata> {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return getDocPageMetadata(page);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import { baseOptions } from '@/lib/layout.shared';
|
||||
import { DocsTopTabs } from '@/components/docs-top-tabs';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/docs'>) {
|
||||
return (
|
||||
<DocsLayout {...baseOptions()} tree={source.getPageTree()} tabs={false}>
|
||||
<DocsTopTabs />
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
|
||||
import { Markdown } from 'fumadocs-core/content/md';
|
||||
import { getTableOfContents } from 'fumadocs-core/content/toc';
|
||||
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Metadata } from 'next';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
|
||||
const title = '无限画布文档';
|
||||
const description = '功能说明、操作手册、部署方式、开发文档、商务合作与赞助支持';
|
||||
|
||||
async function readDocsIndex() {
|
||||
return readFile(join(process.cwd(), 'index.md'), 'utf8');
|
||||
}
|
||||
|
||||
export default async function Page() {
|
||||
const content = await readDocsIndex();
|
||||
const toc = getTableOfContents(content);
|
||||
|
||||
return (
|
||||
<DocsPage toc={toc}>
|
||||
<DocsTitle>{title}</DocsTitle>
|
||||
<DocsDescription>{description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<Markdown components={getMDXComponents()} remarkPlugins={[remarkHeading]}>
|
||||
{content}
|
||||
</Markdown>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/layouts/docs/page';
|
||||
import { Markdown } from 'fumadocs-core/content/md';
|
||||
import { getTableOfContents } from 'fumadocs-core/content/toc';
|
||||
import { remarkHeading } from 'fumadocs-core/mdx-plugins';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import type { Metadata } from 'next';
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
|
||||
const title = '更新日志';
|
||||
const description = '项目版本变更记录';
|
||||
|
||||
async function readChangelog() {
|
||||
return readFile(join(process.cwd(), '..', 'CHANGELOG.md'), 'utf8');
|
||||
}
|
||||
|
||||
export default async function ChangelogPage() {
|
||||
const changelog = await readChangelog();
|
||||
const toc = getTableOfContents(changelog);
|
||||
|
||||
return (
|
||||
<DocsPage toc={toc}>
|
||||
<DocsTitle>{title}</DocsTitle>
|
||||
<DocsDescription>{description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<Markdown components={getMDXComponents()} remarkPlugins={[remarkHeading]}>
|
||||
{changelog}
|
||||
</Markdown>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateMetadata(): Metadata {
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'fumadocs-ui/css/neutral.css';
|
||||
@import 'fumadocs-ui/css/preset.css';
|
||||
|
||||
:root {
|
||||
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
--font-display: 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
html > body[data-scroll-locked] {
|
||||
margin-right: 0px !important;
|
||||
--removed-body-scroll-bar-size: 0px !important;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32 8L58 54H46L32 29L18 54H6L32 8Z" fill="currentColor"/>
|
||||
<path d="M32 40L40 54H24L32 40Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 229 B |
@@ -0,0 +1,12 @@
|
||||
import { Provider } from '@/components/provider';
|
||||
import './global.css';
|
||||
|
||||
export default function Layout({ children }: LayoutProps<'/'>) {
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body className="flex flex-col min-h-screen">
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { getLLMText, source } from '@/lib/source';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const docsIndex = await readFile(join(process.cwd(), 'index.md'), 'utf8');
|
||||
const scan = source.getPages().map(getLLMText);
|
||||
const scanned = await Promise.all(scan);
|
||||
|
||||
return new Response([docsIndex, ...scanned].join('\n\n'));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { getLLMText, getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET(_req: Request, { params }: RouteContext<'/llms.mdx/docs/[[...slug]]'>) {
|
||||
const { slug } = await params;
|
||||
// remove the appended "content.md"
|
||||
const page = source.getPage(slug?.slice(0, -1));
|
||||
if (!page) notFound();
|
||||
|
||||
return new Response(await getLLMText(page), {
|
||||
headers: {
|
||||
'Content-Type': 'text/markdown',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return source.getPages().map((page) => ({
|
||||
slug: getPageMarkdownUrl(page).segments,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { llms } from 'fumadocs-core/source';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const docsIndex = await readFile(join(process.cwd(), 'index.md'), 'utf8');
|
||||
return new Response([docsIndex, llms(source).index()].join('\n\n'));
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const tabs = [
|
||||
{ title: '项目介绍', href: '/docs/overview/quick-start', prefix: '/docs/overview' },
|
||||
{ title: '操作手册', href: '/docs/canvas/canvas-node-manual', prefix: '/docs/canvas' },
|
||||
{ title: '开发文档', href: '/docs/backend/local-development', prefix: '/docs/backend' },
|
||||
{ title: '项目进度', href: '/docs/progress/changelog', prefix: '/docs/progress' },
|
||||
{ title: '商务合作', href: '/docs/business/business', prefix: '/docs/business' },
|
||||
{ title: '赞助支持', href: '/docs/support/donate', prefix: '/docs/support' },
|
||||
];
|
||||
|
||||
export function DocsTopTabs() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-30 hidden h-12 self-start overflow-x-auto border-b bg-fd-background/95 px-6 pt-3 backdrop-blur [grid-area:main] md:flex xl:px-8">
|
||||
<div className="flex flex-row items-end gap-6">
|
||||
{tabs.map((tab) => {
|
||||
const active = tab.prefix ? pathname === tab.href || pathname.startsWith(`${tab.prefix}/`) : pathname === tab.href;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
'inline-flex border-b-2 border-transparent pb-1.5 text-sm font-medium text-nowrap text-fd-muted-foreground transition-colors hover:text-fd-accent-foreground',
|
||||
active && 'border-fd-primary text-fd-primary',
|
||||
)}
|
||||
>
|
||||
{tab.title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import type { MDXComponents } from 'mdx/types';
|
||||
|
||||
export function getMDXComponents(components?: MDXComponents) {
|
||||
return {
|
||||
...defaultMdxComponents,
|
||||
...components,
|
||||
} satisfies MDXComponents;
|
||||
}
|
||||
|
||||
export const useMDXComponents = getMDXComponents;
|
||||
|
||||
declare global {
|
||||
type MDXProvidedComponents = ReturnType<typeof getMDXComponents>;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
import SearchDialog from '@/components/search';
|
||||
import { RootProvider } from 'fumadocs-ui/provider/next';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
export function Provider({ children }: { children: ReactNode }) {
|
||||
return <RootProvider search={{ SearchDialog }}>{children}</RootProvider>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
import {
|
||||
SearchDialog,
|
||||
SearchDialogClose,
|
||||
SearchDialogContent,
|
||||
SearchDialogHeader,
|
||||
SearchDialogIcon,
|
||||
SearchDialogInput,
|
||||
SearchDialogList,
|
||||
SearchDialogOverlay,
|
||||
type SharedProps,
|
||||
} from 'fumadocs-ui/components/dialog/search';
|
||||
import { useDocsSearch } from 'fumadocs-core/search/client';
|
||||
import { create } from '@orama/orama';
|
||||
import { useI18n } from 'fumadocs-ui/contexts/i18n';
|
||||
import { createDocsSearchTokenizer } from '@/lib/search-tokenizer';
|
||||
|
||||
function initOrama() {
|
||||
return create({
|
||||
schema: { _: 'string' },
|
||||
components: {
|
||||
tokenizer: createDocsSearchTokenizer(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default function DefaultSearchDialog(props: SharedProps) {
|
||||
const { locale } = useI18n(); // (optional) for i18n
|
||||
const { search, setSearch, query } = useDocsSearch({
|
||||
type: 'static',
|
||||
initOrama,
|
||||
locale,
|
||||
});
|
||||
|
||||
return (
|
||||
<SearchDialog search={search} onSearchChange={setSearch} isLoading={query.isLoading} {...props}>
|
||||
<SearchDialogOverlay />
|
||||
<SearchDialogContent>
|
||||
<SearchDialogHeader>
|
||||
<SearchDialogIcon />
|
||||
<SearchDialogInput />
|
||||
<SearchDialogClose />
|
||||
</SearchDialogHeader>
|
||||
<SearchDialogList items={query.data !== 'empty' ? query.data : null} />
|
||||
</SearchDialogContent>
|
||||
</SearchDialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { twMerge as cn } from 'tailwind-merge';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getMDXComponents } from '@/components/mdx';
|
||||
import { gitConfig } from '@/lib/shared';
|
||||
import { getPageMarkdownUrl, source } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import { createRelativeLink } from 'fumadocs-ui/mdx';
|
||||
import { DocsBody, DocsDescription, DocsPage, DocsTitle } from 'fumadocs-ui/page';
|
||||
import {
|
||||
MarkdownCopyButton,
|
||||
ViewOptionsPopover,
|
||||
} from 'fumadocs-ui/layouts/docs/page';
|
||||
|
||||
export type DocPageData = (typeof source)['$inferPage'];
|
||||
|
||||
export function DocPageContent({ page }: { page: DocPageData }) {
|
||||
const MDX = page.data.body;
|
||||
const markdownUrl = getPageMarkdownUrl(page).url;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full} className="md:pt-20 xl:pt-24">
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription className="mb-0">{page.data.description}</DocsDescription>
|
||||
<div className="flex flex-row gap-2 items-center border-b pb-6">
|
||||
<MarkdownCopyButton markdownUrl={markdownUrl} />
|
||||
<ViewOptionsPopover
|
||||
markdownUrl={markdownUrl}
|
||||
githubUrl={`https://github.com/${gitConfig.user}/${gitConfig.repo}/blob/${gitConfig.branch}/${gitConfig.docsContentDir}/${page.path}`}
|
||||
/>
|
||||
</div>
|
||||
<DocsBody>
|
||||
<MDX
|
||||
components={getMDXComponents({
|
||||
a: createRelativeLink(source, page),
|
||||
})}
|
||||
/>
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export function getDocPageMetadata(page: DocPageData): Metadata {
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
import { appName, gitConfig } from './shared';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
|
||||
const githubUrl = `https://github.com/${gitConfig.user}/${gitConfig.repo}`;
|
||||
const qqUrl = 'https://qm.qq.com/q/DFnKzZ807u';
|
||||
|
||||
export function baseOptions(): BaseLayoutProps {
|
||||
return {
|
||||
nav: {
|
||||
title: (
|
||||
<span className="inline-flex items-center gap-2 font-semibold">
|
||||
<img src="/logo.svg" alt={appName} className="h-6 w-6" />
|
||||
<span>{appName}</span>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
links: [
|
||||
{
|
||||
text: '文档导航',
|
||||
url: '/docs/overview/quick-start',
|
||||
on: 'nav',
|
||||
},
|
||||
{
|
||||
text: (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span>在线体验</span>
|
||||
<ArrowUpRight className="size-4" />
|
||||
</span>
|
||||
),
|
||||
url: 'https://infinite-canvas-cpco.onrender.com/',
|
||||
external: true,
|
||||
on: 'nav',
|
||||
},
|
||||
{
|
||||
type: 'icon',
|
||||
text: 'GitHub',
|
||||
label: 'GitHub',
|
||||
url: githubUrl,
|
||||
external: true,
|
||||
on: 'menu',
|
||||
icon: <img src="/github.svg" alt="" className="size-4" />,
|
||||
},
|
||||
{
|
||||
type: 'icon',
|
||||
text: 'QQ',
|
||||
label: 'QQ',
|
||||
url: qqUrl,
|
||||
external: true,
|
||||
on: 'menu',
|
||||
icon: <img src="/qq.svg" alt="" className="size-4" />,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
type OramaTokenizer = {
|
||||
language: string;
|
||||
normalizationCache: Map<string, string>;
|
||||
tokenize: (raw: string, language?: string, prop?: string, withCache?: boolean) => string[];
|
||||
};
|
||||
|
||||
const wordPattern = /[\p{Script=Han}]+|[a-z0-9][a-z0-9_'-]*/giu;
|
||||
const hanPattern = /^\p{Script=Han}+$/u;
|
||||
const chineseSegmenter = 'Segmenter' in Intl ? new Intl.Segmenter('zh-CN', { granularity: 'word' }) : null;
|
||||
|
||||
function getChineseSegments(value: string) {
|
||||
if (!chineseSegmenter) return [];
|
||||
|
||||
return Array.from(chineseSegmenter.segment(value))
|
||||
.filter((item) => item.isWordLike)
|
||||
.map((item) => item.segment);
|
||||
}
|
||||
|
||||
function addChineseTokens(tokens: string[], value: string) {
|
||||
const chars = Array.from(value);
|
||||
if (chars.length <= 12) tokens.push(value);
|
||||
tokens.push(...getChineseSegments(value));
|
||||
|
||||
for (let size = 1; size <= 3; size += 1) {
|
||||
if (chars.length < size) continue;
|
||||
for (let i = 0; i <= chars.length - size; i += 1) {
|
||||
tokens.push(chars.slice(i, i + size).join(''));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createDocsSearchTokenizer(): OramaTokenizer {
|
||||
return {
|
||||
language: 'zh-CN',
|
||||
normalizationCache: new Map(),
|
||||
tokenize(raw) {
|
||||
if (typeof raw !== 'string') return [raw];
|
||||
|
||||
const tokens: string[] = [];
|
||||
const input = raw.normalize('NFKC').toLowerCase();
|
||||
|
||||
for (const match of input.matchAll(wordPattern)) {
|
||||
const value = match[0];
|
||||
if (hanPattern.test(value)) {
|
||||
addChineseTokens(tokens, value);
|
||||
} else {
|
||||
tokens.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(tokens.filter(Boolean)));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export const appName = '无限画布';
|
||||
export const docsRoute = '/docs';
|
||||
export const docsContentRoute = '/llms.mdx/docs';
|
||||
|
||||
// fill this with your actual GitHub info, for example:
|
||||
export const gitConfig = {
|
||||
user: 'basketikun',
|
||||
repo: 'infinite-canvas',
|
||||
branch: 'main',
|
||||
docsContentDir: 'docs/content/docs',
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { docs } from 'collections/server';
|
||||
import { loader } from 'fumadocs-core/source';
|
||||
import { docsContentRoute, docsRoute } from './shared';
|
||||
|
||||
// See https://fumadocs.dev/docs/headless/source-api for more info
|
||||
export const source = loader({
|
||||
baseUrl: docsRoute,
|
||||
source: docs.toFumadocsSource(),
|
||||
plugins: [],
|
||||
});
|
||||
|
||||
export function getPageMarkdownUrl(page: (typeof source)['$inferPage']) {
|
||||
const segments = [...page.slugs, 'content.md'];
|
||||
|
||||
return {
|
||||
segments,
|
||||
url: `${docsContentRoute}/${segments.join('/')}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLLMText(page: (typeof source)['$inferPage']) {
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"collections/*": [
|
||||
"./.source/*"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -7,6 +7,8 @@ require (
|
||||
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
|
||||
@@ -27,13 +29,11 @@ require (
|
||||
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/go-sql-driver/mysql v1.8.1 // 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/pgx/v5 v5.6.0 // 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
|
||||
|
||||
@@ -26,6 +26,10 @@ 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")
|
||||
}
|
||||
|
||||
+113
-2
@@ -1,6 +1,9 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -9,7 +12,10 @@ import (
|
||||
"github.com/basketikun/infinite-canvas/config"
|
||||
"github.com/basketikun/infinite-canvas/model"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
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"
|
||||
)
|
||||
@@ -41,6 +47,18 @@ func DB() (*gorm.DB, error) {
|
||||
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
|
||||
@@ -59,10 +77,103 @@ func DB() (*gorm.DB, error) {
|
||||
func dialector(driver string, dsn string) gorm.Dialector {
|
||||
switch driver {
|
||||
case "mysql":
|
||||
return mysql.Open(dsn)
|
||||
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, "`", "``") + "`"
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ func New() *gin.Engine {
|
||||
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) {
|
||||
|
||||
+15
-3
@@ -543,11 +543,23 @@ func decodeState(state string) string {
|
||||
if err != nil {
|
||||
return "/"
|
||||
}
|
||||
redirect := string(data)
|
||||
if !strings.HasPrefix(redirect, "/") {
|
||||
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 redirect
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func RequestOrigin(r *http.Request) string {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export default function nextConfig(phase: string): NextConfig {
|
||||
const releases = parseChangelog(localChangelog);
|
||||
|
||||
return {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: isDev ? ["*.*.*.*"] : [],
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
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 { Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
||||
import { BookOpen, Home, ImageIcon, Images, List, Menu, MessageSquare, Music2, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
import { requestAudioGeneration, storeGeneratedAudio } from "@/services/api/audio";
|
||||
import { requestVideoGeneration, storeGeneratedVideo } from "@/services/api/video";
|
||||
import { DOCS_URL } from "@/constant/env";
|
||||
import { defaultConfig, type AiConfig, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
|
||||
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
|
||||
import { resolveMediaUrl, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||
@@ -17,16 +19,20 @@ 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 } 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 { 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 { CanvasNodeHoverToolbar, CanvasNodeInfoModal } from "../components/canvas-node-hover-toolbar";
|
||||
import { InfiniteCanvas } from "../components/infinite-canvas";
|
||||
@@ -37,6 +43,7 @@ import { CanvasToolbar } from "../components/canvas-toolbar";
|
||||
import { AssetPickerModal, type AssetPickerTab, type InsertAssetPayload } from "../components/asset-picker-modal";
|
||||
import { CanvasZoomControls } from "../components/canvas-zoom-controls";
|
||||
import { useCanvasStore } from "../stores/use-canvas-store";
|
||||
import { buildCanvasResourceReferences, buildNodeMentionReferences } from "../utils/canvas-resource-references";
|
||||
import {
|
||||
CanvasNodeType,
|
||||
type CanvasAssistantImage,
|
||||
@@ -64,6 +71,11 @@ type PendingConnectionCreate = {
|
||||
position: Position;
|
||||
};
|
||||
|
||||
type ConnectionDropTarget = {
|
||||
nodeId: string | null;
|
||||
isNearNode: boolean;
|
||||
};
|
||||
|
||||
type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
||||
chatSessions: CanvasAssistantSession[];
|
||||
activeChatId: string | null;
|
||||
@@ -73,9 +85,17 @@ type CanvasHistoryEntry = Pick<CanvasClipboard, "nodes" | "connections"> & {
|
||||
|
||||
const VIDEO_NODE_MAX_WIDTH = 420;
|
||||
const VIDEO_NODE_MAX_HEIGHT = 420;
|
||||
const CONNECTION_HANDLE_HIT_RADIUS = 40;
|
||||
const CONNECTION_NODE_HIT_PADDING = 32;
|
||||
const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
|
||||
|
||||
要求:
|
||||
1. 只输出提示词正文,不要解释。
|
||||
2. 覆盖主体、构图、风格、光线、色彩、材质、镜头和氛围。
|
||||
3. 尽量写成可直接用于生图模型的完整提示词。`;
|
||||
|
||||
function createCanvasNode(type: CanvasNodeType, position: Position, metadata?: CanvasNodeMetadata): CanvasNodeData {
|
||||
const spec = getNodeSpec(type);
|
||||
@@ -262,6 +282,10 @@ function InfiniteCanvasPage() {
|
||||
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
||||
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);
|
||||
@@ -486,7 +510,7 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const createConnectedNode = useCallback(
|
||||
(type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio, pending: PendingConnectionCreate) => {
|
||||
const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined;
|
||||
const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count) } : undefined;
|
||||
const newNode = createCanvasNode(type, pending.position, metadata);
|
||||
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
|
||||
if (!connection) {
|
||||
@@ -501,7 +525,7 @@ function InfiniteCanvasPage() {
|
||||
setPendingConnectionCreate(null);
|
||||
setConnecting(null);
|
||||
},
|
||||
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting],
|
||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting],
|
||||
);
|
||||
|
||||
const cancelPendingConnectionCreate = useCallback(() => {
|
||||
@@ -509,23 +533,39 @@ function InfiniteCanvasPage() {
|
||||
setConnecting(null);
|
||||
}, [setConnecting]);
|
||||
|
||||
const getConnectableNodeAtPoint = useCallback(
|
||||
(clientX: number, clientY: number, current: ConnectionHandle) => {
|
||||
const getConnectionDropTarget = useCallback(
|
||||
(clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
|
||||
const world = screenToCanvas(clientX, clientY);
|
||||
return (
|
||||
[...nodesRef.current]
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||
.reverse()
|
||||
.find(
|
||||
(node) =>
|
||||
node.id !== current.nodeId &&
|
||||
Boolean(normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) &&
|
||||
world.x >= node.position.x &&
|
||||
world.x <= node.position.x + node.width &&
|
||||
world.y >= node.position.y &&
|
||||
world.y <= node.position.y + node.height,
|
||||
)?.id || null
|
||||
);
|
||||
const scale = Math.max(viewportRef.current.k, 0.05);
|
||||
const padding = CONNECTION_NODE_HIT_PADDING / scale;
|
||||
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
|
||||
let isNearNode = false;
|
||||
let bestNodeId: string | null = null;
|
||||
let bestPriority = Number.POSITIVE_INFINITY;
|
||||
|
||||
[...nodesRef.current]
|
||||
.filter((node) => !isHiddenBatchChild(node, nodesRef.current))
|
||||
.reverse()
|
||||
.forEach((node) => {
|
||||
const anchor = getConnectionTargetAnchor(node, current);
|
||||
const dx = world.x - anchor.x;
|
||||
const dy = world.y - anchor.y;
|
||||
const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius;
|
||||
const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height;
|
||||
const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding;
|
||||
|
||||
if (!hitsHandle && !hitsInside && !hitsExpanded) return;
|
||||
isNearNode = true;
|
||||
if (node.id === current.nodeId || !normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType)) return;
|
||||
|
||||
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
|
||||
if (priority < bestPriority) {
|
||||
bestNodeId = node.id;
|
||||
bestPriority = priority;
|
||||
}
|
||||
});
|
||||
|
||||
return { nodeId: bestNodeId, isNearNode };
|
||||
},
|
||||
[screenToCanvas],
|
||||
);
|
||||
@@ -547,6 +587,10 @@ function InfiniteCanvasPage() {
|
||||
const toolbarNode = toolbarNodeId ? nodeById.get(toolbarNodeId) || null : null;
|
||||
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;
|
||||
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
|
||||
const hasMultipleSelectedNodes = selectedNodeIds.size > 1;
|
||||
@@ -596,7 +640,14 @@ function InfiniteCanvasPage() {
|
||||
});
|
||||
return map;
|
||||
}, [connections, nodes]);
|
||||
|
||||
const resourceContextNodeId = dialogNodeId || activeNodeId;
|
||||
const canvasResourceReferences = useMemo(() => buildCanvasResourceReferences(nodes, connections, resourceContextNodeId), [connections, nodes, resourceContextNodeId]);
|
||||
const resourceReferenceByNodeId = useMemo(() => new Map(canvasResourceReferences.map((reference) => [reference.nodeId, reference])), [canvasResourceReferences]);
|
||||
const mentionReferencesByNodeId = useMemo(() => {
|
||||
const map = new Map<string, ReturnType<typeof buildNodeMentionReferences>>();
|
||||
nodes.forEach((node) => map.set(node.id, buildNodeMentionReferences(node, nodes, connections)));
|
||||
return map;
|
||||
}, [connections, nodes]);
|
||||
const createNode = useCallback(
|
||||
(type: CanvasNodeType, position?: Position) => {
|
||||
const targetPosition = position || getCanvasCenter();
|
||||
@@ -605,7 +656,7 @@ function InfiniteCanvasPage() {
|
||||
? {
|
||||
model: effectiveConfig.imageModel || effectiveConfig.model,
|
||||
size: effectiveConfig.size,
|
||||
count: 3,
|
||||
count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count),
|
||||
}
|
||||
: undefined;
|
||||
const newNode = createCanvasNode(type, targetPosition, configMetadata);
|
||||
@@ -615,7 +666,7 @@ function InfiniteCanvasPage() {
|
||||
setSelectedConnectionId(null);
|
||||
if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id);
|
||||
},
|
||||
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter],
|
||||
);
|
||||
|
||||
const deleteNodes = useCallback(
|
||||
@@ -654,15 +705,22 @@ function InfiniteCanvasPage() {
|
||||
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setAngleNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setContextMenu((current) => (current && allIds.has(current.nodeId) ? null : current));
|
||||
setContextMenu((current) => (current?.type === "node" && allIds.has(current.nodeId) ? null : current));
|
||||
cleanupCanvasFiles({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions });
|
||||
},
|
||||
[chatSessions, cleanupCanvasFiles, projectId],
|
||||
);
|
||||
|
||||
const deleteConnection = useCallback((connectionId: string) => {
|
||||
setConnections((prev) => prev.filter((conn) => conn.id !== connectionId));
|
||||
setSelectedConnectionId((current) => (current === connectionId ? null : current));
|
||||
setContextMenu((current) => (current?.type === "connection" && current.connectionId === connectionId ? null : current));
|
||||
}, []);
|
||||
|
||||
const deselectCanvas = useCallback(() => {
|
||||
cancelPendingConnectionCreate();
|
||||
setSelectedNodeIds(new Set());
|
||||
@@ -680,6 +738,7 @@ function InfiniteCanvasPage() {
|
||||
setConnections([]);
|
||||
setInfoNodeId(null);
|
||||
setCropNodeId(null);
|
||||
setMaskEditNodeId(null);
|
||||
setAngleNodeId(null);
|
||||
setPreviewNodeId(null);
|
||||
setRunningNodeId(null);
|
||||
@@ -986,13 +1045,13 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
|
||||
if (connectingParamsRef.current && !pendingConnectionCreateRef.current) {
|
||||
const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, connectingParamsRef.current);
|
||||
connectionTargetNodeIdRef.current = targetNodeId;
|
||||
setConnectionTargetNodeId(targetNodeId);
|
||||
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, connectingParamsRef.current);
|
||||
connectionTargetNodeIdRef.current = dropTarget.nodeId;
|
||||
setConnectionTargetNodeId(dropTarget.nodeId);
|
||||
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
|
||||
}
|
||||
},
|
||||
[finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas],
|
||||
[finishNodeDrag, getConnectionDropTarget, screenToCanvas],
|
||||
);
|
||||
|
||||
const handleGlobalPointerMove = useCallback(
|
||||
@@ -1040,9 +1099,11 @@ function InfiniteCanvasPage() {
|
||||
|
||||
const currentConnection = connectingParamsRef.current;
|
||||
if (currentConnection) {
|
||||
const targetNodeId = getConnectableNodeAtPoint(event.clientX, event.clientY, currentConnection) || connectionTargetNodeIdRef.current;
|
||||
if (targetNodeId) {
|
||||
connectNodes(currentConnection, targetNodeId);
|
||||
const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, currentConnection);
|
||||
if (dropTarget.nodeId) {
|
||||
connectNodes(currentConnection, dropTarget.nodeId);
|
||||
setConnecting(null);
|
||||
} else if (dropTarget.isNearNode) {
|
||||
setConnecting(null);
|
||||
} else {
|
||||
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
|
||||
@@ -1050,7 +1111,7 @@ function InfiniteCanvasPage() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[connectNodes, finishNodeDrag, getConnectableNodeAtPoint, screenToCanvas, setConnecting],
|
||||
[connectNodes, finishNodeDrag, getConnectionDropTarget, screenToCanvas, setConnecting],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1174,7 +1235,8 @@ function InfiniteCanvasPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement) return;
|
||||
const target = event.target instanceof Element ? event.target : null;
|
||||
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || target?.closest("[contenteditable='true'],[data-canvas-no-zoom]")) return;
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
const isModifierShortcut = event.metaKey || event.ctrlKey;
|
||||
@@ -1217,8 +1279,7 @@ function InfiniteCanvasPage() {
|
||||
if (selectedNodeIdsRef.current.size) {
|
||||
deleteNodes(new Set(selectedNodeIdsRef.current));
|
||||
} else if (selectedConnectionId) {
|
||||
setConnections((prev) => prev.filter((conn) => conn.id !== selectedConnectionId));
|
||||
setSelectedConnectionId(null);
|
||||
deleteConnection(selectedConnectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1234,13 +1295,14 @@ function InfiniteCanvasPage() {
|
||||
setEditingNodeId(null);
|
||||
setInfoNodeId(null);
|
||||
setCropNodeId(null);
|
||||
setMaskEditNodeId(null);
|
||||
setPendingConnectionCreate(null);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [copySelectedNodes, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]);
|
||||
}, [copySelectedNodes, deleteConnection, deleteNodes, pasteCopiedNodes, pasteSystemClipboard, redoCanvas, selectedConnectionId, setConnecting, undoCanvas]);
|
||||
|
||||
const handleConnectStart = useCallback(
|
||||
(event: ReactMouseEvent, nodeId: string, handleType: "source" | "target") => {
|
||||
@@ -1388,6 +1450,53 @@ function InfiniteCanvasPage() {
|
||||
[addAsset, message],
|
||||
);
|
||||
|
||||
const createImageReversePromptNodes = useCallback(
|
||||
(node: CanvasNodeData) => {
|
||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) {
|
||||
message.warning("图片节点为空,无法反推提示词");
|
||||
return;
|
||||
}
|
||||
|
||||
const gap = 96;
|
||||
const textSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
|
||||
const configSpec = NODE_DEFAULT_SIZE[CanvasNodeType.Config];
|
||||
const centerY = node.position.y + node.height / 2;
|
||||
const textNode = {
|
||||
...createCanvasNode(
|
||||
CanvasNodeType.Text,
|
||||
{ x: node.position.x + node.width + gap + textSpec.width / 2, y: centerY },
|
||||
{ content: IMAGE_PROMPT_REVERSE_PRESET, prompt: IMAGE_PROMPT_REVERSE_PRESET, status: NODE_STATUS_SUCCESS, fontSize: 14 },
|
||||
),
|
||||
title: "反推提示词",
|
||||
};
|
||||
const configNode = {
|
||||
...createCanvasNode(
|
||||
CanvasNodeType.Config,
|
||||
{ x: textNode.position.x + textNode.width + gap + configSpec.width / 2, y: centerY },
|
||||
{
|
||||
generationMode: "text",
|
||||
model: effectiveConfig.textModel || effectiveConfig.model || defaultConfig.textModel,
|
||||
count: 1,
|
||||
composerContent: `参考图片:@[node:${node.id}]\n任务说明:@[node:${textNode.id}]`,
|
||||
},
|
||||
),
|
||||
title: "反推提示词配置",
|
||||
};
|
||||
|
||||
setNodes((prev) => [...prev, textNode, configNode]);
|
||||
setConnections((prev) => [
|
||||
...prev,
|
||||
{ id: nanoid(), fromNodeId: node.id, toNodeId: configNode.id },
|
||||
{ id: nanoid(), fromNodeId: textNode.id, toNodeId: configNode.id },
|
||||
]);
|
||||
setSelectedNodeIds(new Set([configNode.id]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(configNode.id);
|
||||
setContextMenu(null);
|
||||
},
|
||||
[effectiveConfig.model, effectiveConfig.textModel, message],
|
||||
);
|
||||
|
||||
const cropImageNode = useCallback(async (node: CanvasNodeData, crop: CanvasImageCropRect) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const cropped = await cropDataUrl(node.metadata.content, crop);
|
||||
@@ -1413,6 +1522,116 @@ 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;
|
||||
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
openConfigDialog(true);
|
||||
return;
|
||||
}
|
||||
const userPrompt = payload.prompt.trim();
|
||||
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`;
|
||||
const childId = nanoid();
|
||||
const source = { id: node.id, name: `${node.title || node.id}.png`, type: node.metadata.mimeType || "image/png", dataUrl: node.metadata.content, storageKey: node.metadata.storageKey };
|
||||
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
|
||||
setMaskEditNodeId(null);
|
||||
setRunningNodeId(childId);
|
||||
setNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: childId,
|
||||
type: CanvasNodeType.Image,
|
||||
title: userPrompt.slice(0, 32) || "局部编辑结果",
|
||||
position: { x: node.position.x + node.width + 96, y: node.position.y },
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
metadata: { prompt, status: NODE_STATUS_LOADING, ...generationMetadata },
|
||||
},
|
||||
]);
|
||||
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(childId);
|
||||
try {
|
||||
const image = await requestEdit(generationConfig, prompt, [source], { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }).then((items) => items[0]);
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), prompt, ...generationMetadata } } : item)));
|
||||
} catch (error) {
|
||||
const errorDetails = error instanceof Error ? error.message : "局部修改失败";
|
||||
message.error(errorDetails);
|
||||
setNodes((prev) => prev.map((item) => (item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails } } : item)));
|
||||
} finally {
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
},
|
||||
[effectiveConfig, isAiConfigReady, message, openConfigDialog],
|
||||
);
|
||||
|
||||
const upscaleImageNode = useCallback(async (node: CanvasNodeData, params: CanvasImageUpscaleParams) => {
|
||||
if (!node.metadata?.content) return;
|
||||
setUpscaleNodeId(null);
|
||||
const upscaled = await upscaleDataUrl(node.metadata.content, params);
|
||||
const image = await uploadImage(upscaled);
|
||||
const size = fitNodeSize(image.width, image.height);
|
||||
const childId = nanoid();
|
||||
const child: CanvasNodeData = {
|
||||
id: childId,
|
||||
type: CanvasNodeType.Image,
|
||||
title: "Upscaled Image",
|
||||
position: { x: node.position.x + node.width + 96, y: node.position.y },
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
metadata: {
|
||||
...imageMetadata(image),
|
||||
prompt: node.metadata?.prompt,
|
||||
},
|
||||
};
|
||||
setNodes((prev) => [...prev, child]);
|
||||
setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
setDialogNodeId(childId);
|
||||
}, []);
|
||||
|
||||
const generateAngleNode = useCallback(
|
||||
async (node: CanvasNodeData, params: CanvasImageAngleParams) => {
|
||||
if (!node.metadata?.content) return;
|
||||
@@ -1606,12 +1825,13 @@ function InfiniteCanvasPage() {
|
||||
);
|
||||
const effectivePrompt = generationContext.prompt.trim();
|
||||
const markSourceStatus = sourceNode?.type !== CanvasNodeType.Image && !editingTextNode;
|
||||
if (!effectivePrompt && mode === "text") {
|
||||
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
|
||||
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
let pendingChildIds: string[] = [];
|
||||
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
|
||||
if (markSourceStatus) setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt: statusPrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)));
|
||||
|
||||
try {
|
||||
if (mode === "image") {
|
||||
@@ -1675,7 +1895,7 @@ function InfiniteCanvasPage() {
|
||||
? isConfigNode
|
||||
? {
|
||||
...node,
|
||||
metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
|
||||
metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined },
|
||||
}
|
||||
: isEmptyImageNode
|
||||
? {
|
||||
@@ -1792,6 +2012,28 @@ function InfiniteCanvasPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "audio") {
|
||||
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Audio];
|
||||
const isEmptyAudioNode = sourceNode?.type === CanvasNodeType.Audio && !sourceNode.metadata?.content;
|
||||
const audioId = isEmptyAudioNode ? nodeId : nanoid();
|
||||
const parent = sourceNode?.position || { x: 0, y: 0 };
|
||||
const audioNode: CanvasNodeData = {
|
||||
id: audioId,
|
||||
type: CanvasNodeType.Audio,
|
||||
title: effectivePrompt.slice(0, 32) || "Generated Audio",
|
||||
position: isEmptyAudioNode ? sourceNode.position : { x: parent.x + (sourceNode?.width || spec.width) + 96, y: parent.y + ((sourceNode?.height || spec.height) - spec.height) / 2 },
|
||||
width: isEmptyAudioNode ? sourceNode.width : spec.width,
|
||||
height: isEmptyAudioNode ? sourceNode.height : spec.height,
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...buildAudioGenerationMetadata(generationConfig) },
|
||||
};
|
||||
pendingChildIds = [audioId];
|
||||
setNodes((prev) => (isEmptyAudioNode ? prev.map((node) => (node.id === nodeId ? { ...node, ...audioNode } : node)) : [...prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_SUCCESS } } : node)), audioNode]));
|
||||
if (!isEmptyAudioNode) setConnections((prev) => [...prev, { id: nanoid(), fromNodeId: nodeId, toNodeId: audioId }]);
|
||||
const audio = await storeGeneratedAudio(await requestAudioGeneration(generationConfig, effectivePrompt), generationConfig.audioFormat);
|
||||
setNodes((prev) => prev.map((node) => (node.id === audioId ? { ...node, metadata: { ...node.metadata, ...audioMetadata(audio), prompt: effectivePrompt, ...buildAudioGenerationMetadata(generationConfig) } } : node)));
|
||||
return;
|
||||
}
|
||||
|
||||
let streamed = "";
|
||||
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
|
||||
const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1;
|
||||
@@ -1804,16 +2046,16 @@ function InfiniteCanvasPage() {
|
||||
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
||||
id,
|
||||
type: CanvasNodeType.Text,
|
||||
title: prompt.slice(0, 32) || "Generated Text",
|
||||
title: effectivePrompt.slice(0, 32) || "Generated Text",
|
||||
position: {
|
||||
x: parentPosition.x + parentConfig.width + 96,
|
||||
y: parentPosition.y + parentConfig.height / 2 - textConfig.height / 2 + (index - (textCount - 1) / 2) * (textConfig.height + 36),
|
||||
},
|
||||
width: textConfig.width,
|
||||
height: textConfig.height,
|
||||
metadata: { prompt, status: NODE_STATUS_LOADING, fontSize: 14 },
|
||||
metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, fontSize: 14 },
|
||||
}));
|
||||
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
|
||||
setNodes((prev) => [...prev.map((node) => (node.id === nodeId && isConfigNode ? { ...node, metadata: { ...node.metadata, prompt: effectivePrompt, status: NODE_STATUS_LOADING, errorDetails: undefined } } : node)), ...childNodes]);
|
||||
setConnections((prev) => [...prev, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: nodeId, toNodeId: childId }))]);
|
||||
}
|
||||
|
||||
@@ -1868,7 +2110,7 @@ function InfiniteCanvasPage() {
|
||||
size: savedImageMetadata.size || effectiveConfig.size,
|
||||
count: "1",
|
||||
}
|
||||
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : "image"), count: "1" };
|
||||
: { ...buildGenerationConfig(effectiveConfig, sourceNode, node.type === CanvasNodeType.Text ? "text" : node.type === CanvasNodeType.Video ? "video" : node.type === CanvasNodeType.Audio ? "audio" : "image"), count: "1" };
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
openConfigDialog(true);
|
||||
return;
|
||||
@@ -1911,6 +2153,11 @@ function InfiniteCanvasPage() {
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, width: videoSize.width, height: videoSize.height, position: { x: item.position.x + item.width / 2 - videoSize.width / 2, y: item.position.y + item.height / 2 - videoSize.height / 2 }, metadata: { ...item.metadata, ...videoMetadata(video), prompt, model: generationConfig.model, size: generationConfig.size, seconds: generationConfig.videoSeconds, vquality: generationConfig.vquality, generateAudio: generationConfig.videoGenerateAudio, watermark: generationConfig.videoWatermark } } : item)));
|
||||
return;
|
||||
}
|
||||
if (node.type === CanvasNodeType.Audio) {
|
||||
const audio = await storeGeneratedAudio(await requestAudioGeneration(generationConfig, prompt), generationConfig.audioFormat);
|
||||
setNodes((prev) => prev.map((item) => (item.id === node.id ? { ...item, metadata: { ...item.metadata, ...audioMetadata(audio), prompt, ...buildAudioGenerationMetadata(generationConfig) } } : item)));
|
||||
return;
|
||||
}
|
||||
|
||||
const image = useReferenceImages ? await requestEdit(generationConfig, prompt, retryImages).then((items) => items[0]) : await requestGeneration(generationConfig, prompt).then((items) => items[0]);
|
||||
const uploadedImage = await uploadImage(image.dataUrl);
|
||||
@@ -1963,7 +2210,7 @@ function InfiniteCanvasPage() {
|
||||
prompt: "",
|
||||
model: effectiveConfig.imageModel || effectiveConfig.model,
|
||||
size: effectiveConfig.size,
|
||||
count: 3,
|
||||
count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count),
|
||||
},
|
||||
);
|
||||
const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id };
|
||||
@@ -1977,7 +2224,7 @@ function InfiniteCanvasPage() {
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(configNode.id);
|
||||
},
|
||||
[effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message],
|
||||
[effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message],
|
||||
);
|
||||
|
||||
const insertAssistantImage = useCallback(
|
||||
@@ -2105,10 +2352,15 @@ function InfiniteCanvasPage() {
|
||||
setSelectedNodeIds(new Set());
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
setSelectedConnectionId(connection.id);
|
||||
setSelectedNodeIds(new Set());
|
||||
setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId: connection.id });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{connectingParams ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} /> : null}
|
||||
{connectingParams ? <ActiveConnectionPath node={nodeById.get(connectingParams.nodeId)} handle={connectingParams} mouseWorld={mouseWorld} target={connectionTargetNodeId ? nodeById.get(connectionTargetNodeId) : undefined} /> : null}
|
||||
</svg>
|
||||
|
||||
{visibleNodes.map((node) => (
|
||||
@@ -2130,30 +2382,41 @@ function InfiniteCanvasPage() {
|
||||
batchRecovering={collapsingBatchIds.has(node.id)}
|
||||
batchMotion={batchMotionById.get(node.id)}
|
||||
showImageInfo={showImageInfo}
|
||||
renderPanel={(panelNode) => (
|
||||
<CanvasNodePromptPanel
|
||||
node={panelNode}
|
||||
isRunning={runningNodeId === panelNode.id}
|
||||
onPromptChange={handleNodePromptChange}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onGenerate={handleGenerateNode}
|
||||
onImageSettingsOpenChange={(open) => {
|
||||
setNodeImageSettingsOpen(open);
|
||||
if (open) setToolbarNodeId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
resourceLabel={resourceReferenceByNodeId.get(node.id)}
|
||||
mentionReferences={mentionReferencesByNodeId.get(node.id) || []}
|
||||
renderPanel={(panelNode) =>
|
||||
panelNode.type === CanvasNodeType.Config ? (
|
||||
<CanvasConfigComposer
|
||||
value={panelNode.metadata?.composerContent ?? panelNode.metadata?.prompt ?? ""}
|
||||
inputs={configInputsById.get(panelNode.id) || []}
|
||||
onChange={(composerContent) => handleConfigNodeChange(panelNode.id, { composerContent })}
|
||||
onClose={() => setDialogNodeId(null)}
|
||||
/>
|
||||
) : (
|
||||
<CanvasNodePromptPanel
|
||||
node={panelNode}
|
||||
isRunning={runningNodeId === panelNode.id}
|
||||
mentionReferences={mentionReferencesByNodeId.get(panelNode.id) || []}
|
||||
onPromptChange={handleNodePromptChange}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onGenerate={handleGenerateNode}
|
||||
onImageSettingsOpenChange={(open) => {
|
||||
setNodeImageSettingsOpen(open);
|
||||
if (open) setToolbarNodeId(null);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
renderNodeContent={(contentNode) => (
|
||||
<CanvasConfigNodePanel
|
||||
node={contentNode}
|
||||
isRunning={runningNodeId === contentNode.id}
|
||||
inputSummary={getInputSummary(configInputsById.get(contentNode.id) || [])}
|
||||
inputs={configInputsById.get(contentNode.id) || []}
|
||||
onConfigChange={handleConfigNodeChange}
|
||||
onTextInputChange={handleNodeContentChange}
|
||||
onComposerToggle={() => setDialogNodeId((current) => (current === contentNode.id ? null : contentNode.id))}
|
||||
onGenerate={(nodeId) => {
|
||||
const target = nodesRef.current.find((item) => item.id === nodeId);
|
||||
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.prompt || "");
|
||||
void handleGenerateNode(nodeId, target?.metadata?.generationMode || "image", target?.metadata?.composerContent ?? target?.metadata?.prompt ?? "");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -2174,6 +2437,7 @@ function InfiniteCanvasPage() {
|
||||
onSetBatchPrimary={setBatchPrimary}
|
||||
onRetry={(node) => void handleRetryNode(node)}
|
||||
onGenerateImage={generateImageFromTextNode}
|
||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||
onContextMenu={(event, id) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -2212,9 +2476,14 @@ function InfiniteCanvasPage() {
|
||||
onUpload={(node) => handleUploadRequest(node.id)}
|
||||
onDownload={downloadNodeImage}
|
||||
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)}
|
||||
onViewImage={(node) => setPreviewNodeId(node.id)}
|
||||
onReversePrompt={createImageReversePromptNodes}
|
||||
onRetry={(node) => void handleRetryNode(node)}
|
||||
onToggleFreeResize={(node) => toggleNodeFreeResize(node.id)}
|
||||
onDelete={(node) => deleteNodes(new Set([node.id]))}
|
||||
@@ -2258,11 +2527,16 @@ function InfiniteCanvasPage() {
|
||||
menu={contextMenu}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onDuplicate={() => {
|
||||
if (contextMenu.type !== "node") return;
|
||||
duplicateNode(contextMenu.nodeId);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
onDelete={() => {
|
||||
deleteNodes(new Set([contextMenu.nodeId]));
|
||||
if (contextMenu.type === "node") {
|
||||
deleteNodes(new Set([contextMenu.nodeId]));
|
||||
} else {
|
||||
deleteConnection(contextMenu.connectionId);
|
||||
}
|
||||
setContextMenu(null);
|
||||
}}
|
||||
/>
|
||||
@@ -2274,6 +2548,16 @@ function InfiniteCanvasPage() {
|
||||
|
||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open={Boolean(cropNode)} onClose={() => setCropNodeId(null)} onConfirm={(crop) => void cropImageNode(cropNode!, crop)} /> : null}
|
||||
|
||||
{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)}>
|
||||
<div className="py-8 text-center text-base font-medium">暂未实现</div>
|
||||
</Modal>
|
||||
|
||||
{angleNode?.metadata?.content ? <CanvasNodeAngleDialog dataUrl={angleNode.metadata.content} open={Boolean(angleNode)} onClose={() => setAngleNodeId(null)} onConfirm={(params) => void generateAngleNode(angleNode!, params)} /> : null}
|
||||
|
||||
<Modal
|
||||
@@ -2405,6 +2689,7 @@ function CanvasTopBar({
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "home", icon: <Home className="size-4" />, label: "主页", onClick: onHome },
|
||||
{ key: "docs", icon: <BookOpen className="size-4" />, label: "文档", onClick: () => window.open(DOCS_URL, "_blank", "noopener,noreferrer") },
|
||||
{ key: "projects", icon: <Images className="size-4" />, label: "我的画布", onClick: onProjects },
|
||||
{ type: "divider" },
|
||||
{ key: "new", icon: <Plus className="size-4" />, label: "新建画布", onClick: onCreateProject },
|
||||
@@ -2533,7 +2818,12 @@ function imageExtension(dataUrl: string) {
|
||||
}
|
||||
|
||||
function audioExtension(mimeType?: string) {
|
||||
return mimeType?.includes("wav") ? "wav" : "mp3";
|
||||
if (mimeType?.includes("wav")) return "wav";
|
||||
if (mimeType?.includes("opus")) return "opus";
|
||||
if (mimeType?.includes("aac")) return "aac";
|
||||
if (mimeType?.includes("flac")) return "flac";
|
||||
if (mimeType?.includes("pcm")) return "pcm";
|
||||
return "mp3";
|
||||
}
|
||||
|
||||
function imageMetadata(image: UploadedImage): CanvasNodeMetadata {
|
||||
@@ -2559,6 +2849,16 @@ function buildImageGenerationMetadata(type: CanvasImageGenerationType, config: A
|
||||
};
|
||||
}
|
||||
|
||||
function buildAudioGenerationMetadata(config: AiConfig): CanvasNodeMetadata {
|
||||
return {
|
||||
model: config.model,
|
||||
audioVoice: config.audioVoice,
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
audioInstructions: config.audioInstructions,
|
||||
};
|
||||
}
|
||||
|
||||
function referenceUrl(image: ReferenceImage) {
|
||||
return image.storageKey || image.url || (!image.dataUrl.startsWith("data:") ? image.dataUrl : undefined);
|
||||
}
|
||||
@@ -2631,6 +2931,13 @@ function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<CanvasNodeDat
|
||||
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
||||
}
|
||||
|
||||
function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle) {
|
||||
return {
|
||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||
y: node.position.y + node.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConnection(firstNodeId: string, secondNodeId: string, nodes: CanvasNodeData[], firstHandleType: "source" | "target") {
|
||||
const first = nodes.find((node) => node.id === firstNodeId);
|
||||
const second = nodes.find((node) => node.id === secondNodeId);
|
||||
@@ -2652,17 +2959,21 @@ function getInputSummary(inputs: NodeGenerationInput[]) {
|
||||
}
|
||||
|
||||
function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode): AiConfig {
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : config.textModel;
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
return {
|
||||
...config,
|
||||
model: node?.metadata?.model || defaultModel || config.model || defaultConfig.model,
|
||||
model: node?.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : config.model || defaultConfig.model),
|
||||
quality: node?.metadata?.quality || config.quality || defaultConfig.quality,
|
||||
size: node?.metadata?.size || config.size || defaultConfig.size,
|
||||
videoSeconds: node?.metadata?.seconds || config.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark,
|
||||
count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count),
|
||||
audioVoice: node?.metadata?.audioVoice || config.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node?.metadata?.audioFormat || config.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node?.metadata?.audioSpeed || config.audioSpeed || defaultConfig.audioSpeed,
|
||||
audioInstructions: node?.metadata?.audioInstructions || config.audioInstructions || defaultConfig.audioInstructions,
|
||||
count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]);
|
||||
const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]);
|
||||
const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]);
|
||||
const assistantConfig = useMemo(() => ({ ...effectiveConfig, count: effectiveConfig.canvasImageCount || effectiveConfig.count }), [effectiveConfig]);
|
||||
const iconButtonStyle = { color: theme.node.muted };
|
||||
|
||||
useEffect(() => {
|
||||
@@ -140,7 +141,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
};
|
||||
|
||||
const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => {
|
||||
const requestConfig = { ...effectiveConfig, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model };
|
||||
const requestConfig = { ...effectiveConfig, count: nextMode === "image" ? effectiveConfig.canvasImageCount || effectiveConfig.count : effectiveConfig.count, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model };
|
||||
if (!isAiConfigReady(requestConfig, requestConfig.model)) {
|
||||
openConfigDialog(true);
|
||||
return;
|
||||
@@ -319,11 +320,11 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS
|
||||
prompt={prompt}
|
||||
isRunning={isRunning}
|
||||
references={selectedReferences}
|
||||
config={effectiveConfig}
|
||||
config={assistantConfig}
|
||||
onModeChange={setMode}
|
||||
onPromptChange={setPrompt}
|
||||
onSubmit={submit}
|
||||
onConfigChange={updateConfig}
|
||||
onConfigChange={(key, value) => updateConfig(key === "count" ? "canvasImageCount" : key, value)}
|
||||
onMissingConfig={() => openConfigDialog(true)}
|
||||
onRemoveReference={(id) => {
|
||||
setRemovedReferenceIds((prev) => new Set(prev).add(id));
|
||||
@@ -429,11 +430,11 @@ function AssistantComposer({
|
||||
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} capability="image" onMissingConfig={onMissingConfig} />
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} capability="text" onMissingConfig={onMissingConfig} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
|
||||
import { AudioSettingsPanel } from "@/components/audio-settings-panel";
|
||||
import { audioFormatLabel, audioSpeedLabel, audioVoiceLabel } from "@/lib/audio-generation";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
export type CanvasAudioSettingKey = "audioVoice" | "audioFormat" | "audioSpeed" | "audioInstructions";
|
||||
|
||||
type CanvasAudioSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
|
||||
buttonClassName?: string;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
};
|
||||
|
||||
export function CanvasAudioSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasAudioSettingsPopoverProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
|
||||
const closeOnOutsidePointer = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
syncPosition();
|
||||
window.addEventListener("resize", syncPosition);
|
||||
window.addEventListener("scroll", syncPosition, true);
|
||||
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
return () => {
|
||||
window.removeEventListener("resize", syncPosition);
|
||||
window.removeEventListener("scroll", syncPosition, true);
|
||||
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const panel = open && buttonRect ? <AudioSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
|
||||
<span className="truncate">
|
||||
{audioVoiceLabel(config.audioVoice)} · {audioFormatLabel(config.audioFormat)} · {audioSpeedLabel(config.audioSpeed)}
|
||||
</span>
|
||||
</Button>
|
||||
</span>
|
||||
{panel}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioSettingsPortal({
|
||||
buttonRect,
|
||||
panelRef,
|
||||
placement,
|
||||
theme,
|
||||
config,
|
||||
onConfigChange,
|
||||
}: {
|
||||
buttonRect: DOMRect;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
placement: CanvasAudioSettingsPopoverProps["placement"];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: CanvasAudioSettingKey, value: string) => void;
|
||||
}) {
|
||||
const width = 356;
|
||||
const gap = 8;
|
||||
const margin = 12;
|
||||
const alignRight = placement?.endsWith("Right");
|
||||
const alignCenter = placement === "top" || placement === "bottom";
|
||||
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
|
||||
const topPlacement = placement?.startsWith("top");
|
||||
const style = {
|
||||
position: "fixed",
|
||||
zIndex: 1200,
|
||||
width,
|
||||
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
|
||||
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
|
||||
background: theme.toolbar.panel,
|
||||
borderRadius: 18,
|
||||
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
|
||||
padding: 18,
|
||||
overflowY: "auto",
|
||||
color: theme.node.text,
|
||||
} as const;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="canvas-image-settings-popover"
|
||||
style={style}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<AudioSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, KeyboardEvent, MouseEvent, PointerEvent } from "react";
|
||||
import { Button, Image } from "antd";
|
||||
import { FileText, Image as ImageIcon, Music2, Video, X } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||
|
||||
type CanvasConfigComposerProps = {
|
||||
value: string;
|
||||
inputs: NodeGenerationInput[];
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type Token =
|
||||
| { type: "text"; value: string }
|
||||
| { type: "reference"; nodeId: string };
|
||||
|
||||
type MentionState = {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const CONFIG_REFERENCE_PATTERN = /@\[node:([^\]]+)\]/g;
|
||||
|
||||
export function CanvasConfigComposer({ value, inputs, onChange, onClose }: CanvasConfigComposerProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const composingRef = useRef(false);
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const tokens = useMemo(() => parseComposerTokens(value), [value]);
|
||||
const referenceById = useMemo(() => new Map(inputs.map((input) => [input.nodeId, input])), [inputs]);
|
||||
const candidates = useMemo(() => {
|
||||
if (!mention) return [];
|
||||
const query = (mention.query || "").trim().toLowerCase();
|
||||
if (!query) return inputs;
|
||||
return inputs.filter((input) => `${resourceLabel(input, inputs)} ${input.title} ${input.text || ""}`.toLowerCase().includes(query));
|
||||
}, [inputs, mention]);
|
||||
|
||||
useEffect(() => {
|
||||
if (document.activeElement === editorRef.current) return;
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
editor.textContent = "";
|
||||
tokens.forEach((token) => {
|
||||
if (token.type === "text") {
|
||||
editor.append(document.createTextNode(token.value));
|
||||
return;
|
||||
}
|
||||
const input = referenceById.get(token.nodeId);
|
||||
if (input) editor.append(createReferenceChip(input, inputs, theme, setImagePreview));
|
||||
});
|
||||
}, [inputs, referenceById, theme, tokens]);
|
||||
|
||||
const syncFromEditor = () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
const next = serializeEditor(editor);
|
||||
onChange(next);
|
||||
syncMention();
|
||||
};
|
||||
|
||||
const syncMention = () => {
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match || !inputs.length) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
setMention({ query: match[1] || "" });
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const closeMention = () => {
|
||||
setMention(null);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const insertReference = (input: NodeGenerationInput) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
removeActiveMention();
|
||||
const chip = createReferenceChip(input, inputs, theme, setImagePreview);
|
||||
const space = document.createTextNode(" ");
|
||||
const selection = window.getSelection();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (range) {
|
||||
range.insertNode(space);
|
||||
range.insertNode(chip);
|
||||
range.setStartAfter(space);
|
||||
range.collapse(true);
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
} else {
|
||||
editor.append(chip, space);
|
||||
placeCaretAtEnd(editor);
|
||||
}
|
||||
closeMention();
|
||||
onChange(serializeEditor(editor));
|
||||
};
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => event.stopPropagation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="rounded-2xl border p-3 shadow-2xl backdrop-blur"
|
||||
style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">组装提示词</div>
|
||||
<div className="truncate text-[11px] opacity-55">@ 引用已连接素材,发送前按当前连接重新编号</div>
|
||||
</div>
|
||||
<Button size="small" type="text" className="!h-7 !w-7 !min-w-7 !p-0" icon={<X className="size-3.5" />} onClick={onClose} />
|
||||
</div>
|
||||
<div className="relative rounded-xl border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接的图片或文本</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="thin-scrollbar min-h-28 w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
|
||||
style={{ color: theme.node.text }}
|
||||
onInput={() => {
|
||||
if (!composingRef.current) syncFromEditor();
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
composingRef.current = true;
|
||||
}}
|
||||
onCompositionEnd={() => {
|
||||
composingRef.current = false;
|
||||
syncFromEditor();
|
||||
}}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if ((event.key === "Backspace" || event.key === "Delete") && deleteAdjacentReference(event.key)) {
|
||||
event.preventDefault();
|
||||
requestAnimationFrame(syncFromEditor);
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(syncMention);
|
||||
}}
|
||||
onBlur={() => window.setTimeout(closeMention, 120)}
|
||||
/>
|
||||
{mention && candidates.length ? <MentionMenu inputs={candidates} allInputs={inputs} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null}
|
||||
</div>
|
||||
{imagePreview ? <Image src={imagePreview} alt="引用图片预览" style={{ display: "none" }} preview={{ visible: true, src: imagePreview, onVisibleChange: (visible) => !visible && setImagePreview(null) }} /> : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
function MentionMenu({ inputs, allInputs, activeIndex, theme, onSelect }: { inputs: NodeGenerationInput[]; allInputs: NodeGenerationInput[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (input: NodeGenerationInput) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const selectInput = (input: NodeGenerationInput) => {
|
||||
if (selectedRef.current) return;
|
||||
selectedRef.current = true;
|
||||
onSelect(input);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-2 top-[calc(100%+6px)] z-[90] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl" style={{ background: theme.toolbar.panel, borderColor: theme.toolbar.border }}>
|
||||
{inputs.map((input, index) => (
|
||||
<button
|
||||
key={input.nodeId}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectInput(input);
|
||||
}}
|
||||
>
|
||||
<ResourcePreview input={input} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{resourceLabel(input, allInputs)}</span>
|
||||
<span className="block truncate opacity-65">{input.text || input.title}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResourcePreview({ input }: { input: NodeGenerationInput }) {
|
||||
if (input.type === "image" && input.image) return <img src={input.image.dataUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||
if (input.type === "video" && input.video) return <video src={input.video.url} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||
const Icon = input.type === "audio" ? Music2 : input.type === "video" ? Video : input.type === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function createReferenceChip(input: NodeGenerationInput, inputs: NodeGenerationInput[], theme: (typeof canvasThemes)[keyof typeof canvasThemes], onImagePreview: (url: string) => void) {
|
||||
const wrapper = document.createElement("span");
|
||||
wrapper.contentEditable = "false";
|
||||
wrapper.dataset.referenceNodeId = input.nodeId;
|
||||
wrapper.className = "mx-px inline-flex h-7 max-w-40 items-center justify-center overflow-hidden rounded-md border px-1 text-xs leading-none align-middle";
|
||||
Object.assign(wrapper.style, chipStyle(theme));
|
||||
if (input.type === "image" && input.image) {
|
||||
const image = document.createElement("img");
|
||||
image.src = input.image.dataUrl;
|
||||
image.alt = input.title;
|
||||
image.className = "size-6 rounded object-cover";
|
||||
wrapper.className = "mx-px inline-flex size-6 items-center justify-center overflow-hidden rounded align-middle";
|
||||
wrapper.appendChild(image);
|
||||
wrapper.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onImagePreview(input.image?.dataUrl || "");
|
||||
});
|
||||
} else {
|
||||
wrapper.title = input.text || input.title;
|
||||
const text = document.createElement("span");
|
||||
text.className = "block truncate";
|
||||
text.textContent = input.type === "text" ? input.text || input.title : input.title;
|
||||
wrapper.appendChild(text);
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function serializeEditor(editor: HTMLElement) {
|
||||
return serializeNodes(editor.childNodes).replace(/\uFEFF/g, "");
|
||||
}
|
||||
|
||||
function serializeNodes(nodes: NodeListOf<ChildNode>) {
|
||||
let result = "";
|
||||
nodes.forEach((node) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) result += node.textContent || "";
|
||||
if (!(node instanceof HTMLElement)) return;
|
||||
const nodeId = node.dataset.referenceNodeId;
|
||||
if (nodeId) result += `@[node:${nodeId}]`;
|
||||
else if (node.tagName === "BR") result += "\n";
|
||||
else result += serializeNodes(node.childNodes);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function removeActiveMention() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
const text = textBeforeCaret();
|
||||
const match = /@([^\s@]*)$/.exec(text);
|
||||
if (!match) return;
|
||||
range.setStart(range.startContainer, Math.max(0, range.startOffset - (match[1] || "").length - 1));
|
||||
range.deleteContents();
|
||||
}
|
||||
|
||||
function deleteAdjacentReference(key: string) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount || !selection.isCollapsed) return false;
|
||||
const range = selection.getRangeAt(0);
|
||||
const target = adjacentReferenceNode(range, key);
|
||||
if (!target) return false;
|
||||
const nextCaretNode = document.createTextNode("");
|
||||
target.replaceWith(nextCaretNode);
|
||||
range.setStart(nextCaretNode, 0);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return true;
|
||||
}
|
||||
|
||||
function adjacentReferenceNode(range: Range, key: string) {
|
||||
const container = range.startContainer;
|
||||
const offset = range.startOffset;
|
||||
const previous = key === "Backspace";
|
||||
if (container.nodeType === Node.TEXT_NODE) {
|
||||
const text = container.textContent || "";
|
||||
if ((previous && offset > 0) || (!previous && offset < text.length)) return null;
|
||||
return findReferenceSibling(container, previous);
|
||||
}
|
||||
const children = Array.from(container.childNodes);
|
||||
return findReferenceSibling(children[previous ? offset - 1 : offset] || container, previous, true);
|
||||
}
|
||||
|
||||
function findReferenceSibling(node: Node, previous: boolean, includeSelf = false): HTMLElement | null {
|
||||
let current: Node | null = includeSelf ? node : previous ? node.previousSibling : node.nextSibling;
|
||||
while (current && current.nodeType === Node.TEXT_NODE && !(current.textContent || "").trim()) current = previous ? current.previousSibling : current.nextSibling;
|
||||
return current instanceof HTMLElement && current.dataset.referenceNodeId ? current : null;
|
||||
}
|
||||
|
||||
function textBeforeCaret() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection?.rangeCount) return "";
|
||||
const range = selection.getRangeAt(0).cloneRange();
|
||||
const editor = closestEditor(range.startContainer);
|
||||
if (!editor) return "";
|
||||
range.setStart(editor, 0);
|
||||
return range.toString();
|
||||
}
|
||||
|
||||
function closestEditor(node: Node) {
|
||||
const element = node instanceof Element ? node : node.parentElement;
|
||||
return element?.closest("[contenteditable='true']") || null;
|
||||
}
|
||||
|
||||
function placeCaretAtEnd(element: HTMLElement) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}
|
||||
|
||||
function parseComposerTokens(value: string): Token[] {
|
||||
const tokens: Token[] = [];
|
||||
let lastIndex = 0;
|
||||
for (const match of value.matchAll(CONFIG_REFERENCE_PATTERN)) {
|
||||
if (match.index === undefined) continue;
|
||||
if (match.index > lastIndex) tokens.push({ type: "text", value: value.slice(lastIndex, match.index) });
|
||||
tokens.push({ type: "reference", nodeId: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < value.length) tokens.push({ type: "text", value: value.slice(lastIndex) });
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function resourceLabel(input: NodeGenerationInput, inputs: NodeGenerationInput[]) {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
const index = Math.max(0, sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId));
|
||||
if (input.type === "image") return `图片${index + 1}`;
|
||||
if (input.type === "video") return `视频${index + 1}`;
|
||||
if (input.type === "audio") return `音频${index + 1}`;
|
||||
return `文本${index + 1}`;
|
||||
}
|
||||
|
||||
function chipStyle(theme: (typeof canvasThemes)[keyof typeof canvasThemes]): CSSProperties {
|
||||
return { background: theme.toolbar.panel, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
}
|
||||
@@ -1,74 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useState } from "react";
|
||||
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Video } from "lucide-react";
|
||||
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
|
||||
import { Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Video } from "lucide-react";
|
||||
import { Button, Segmented } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
import type { NodeGenerationInput } from "./canvas-node-generation";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
|
||||
|
||||
type CanvasConfigNodePanelProps = {
|
||||
node: CanvasNodeData;
|
||||
isRunning: boolean;
|
||||
inputSummary: { textCount: number; imageCount: number; videoCount: number; audioCount: number };
|
||||
inputs: NodeGenerationInput[];
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeMetadata>) => void;
|
||||
onTextInputChange: (nodeId: string, content: string) => void;
|
||||
onGenerate: (nodeId: string) => void;
|
||||
onComposerToggle: () => void;
|
||||
};
|
||||
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, onConfigChange, onTextInputChange, onGenerate }: CanvasConfigNodePanelProps) {
|
||||
const { message } = App.useApp();
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
const [editingTextId, setEditingTextId] = useState<string | null>(null);
|
||||
const [editingText, setEditingText] = useState("");
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle }: CanvasConfigNodePanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1)));
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 });
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const textInputs = inputs.filter((input) => input.type === "text");
|
||||
const imageInputs = inputs.filter((input) => input.type === "image");
|
||||
const videoInputs = inputs.filter((input) => input.type === "video");
|
||||
const audioInputs = inputs.filter((input) => input.type === "audio");
|
||||
|
||||
const moveInput = (input: NodeGenerationInput, offset: number) => {
|
||||
const sameTypeInputs = inputs.filter((item) => item.type === input.type);
|
||||
const sameTypeIndex = sameTypeInputs.findIndex((item) => item.nodeId === input.nodeId);
|
||||
const targetInput = sameTypeInputs[sameTypeIndex + offset];
|
||||
if (!targetInput) return;
|
||||
const index = inputs.findIndex((item) => item.nodeId === input.nodeId);
|
||||
const targetIndex = inputs.findIndex((item) => item.nodeId === targetInput.nodeId);
|
||||
const next = [...inputs];
|
||||
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
||||
onConfigChange(node.id, { inputOrder: next.map((input) => input.nodeId) });
|
||||
message.success("已调整输入顺序");
|
||||
};
|
||||
const startTextEdit = (input: NodeGenerationInput) => {
|
||||
setEditingTextId(input.nodeId);
|
||||
setEditingText(input.text || "");
|
||||
};
|
||||
const saveTextEdit = () => {
|
||||
if (!editingTextId) return;
|
||||
onTextInputChange(editingTextId, editingText);
|
||||
setEditingText("");
|
||||
setEditingTextId(null);
|
||||
message.success("已保存文本提示词");
|
||||
};
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
@@ -108,35 +75,46 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "audio",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Music2 className="size-3.5" />
|
||||
音频
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex flex-wrap gap-1.5" onMouseDown={(event) => event.stopPropagation()}>
|
||||
<div className="mb-2 flex flex-wrap gap-1.5">
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onClick={() => setPreviewOpen(true)}>
|
||||
<Eye className="size-3.5" />
|
||||
预览
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[11px]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||
<Settings2 className="size-3.5" />
|
||||
组装提示词
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "image" || mode === "video" || mode === "audio" ? "grid-cols-[minmax(0,1fr)_148px]" : "grid-cols-1"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth />
|
||||
{mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : mode === "image" ? (
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
|
||||
) : mode === "audio" ? (
|
||||
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
|
||||
disabled={isRunning || (!inputSummary.textCount && !inputSummary.imageCount && !inputSummary.videoCount && !inputSummary.audioCount)}
|
||||
disabled={isRunning || !canGenerate}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onGenerate(node.id)}
|
||||
>
|
||||
@@ -149,238 +127,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
<span>开始生成</span>
|
||||
</span>
|
||||
</Button>
|
||||
<Modal
|
||||
title="输入预览"
|
||||
open={previewOpen}
|
||||
onCancel={() => setPreviewOpen(false)}
|
||||
footer={null}
|
||||
centered
|
||||
width={860}
|
||||
mask={{ closable: true }}
|
||||
keyboard
|
||||
destroyOnHidden
|
||||
modalRender={(modal) => (
|
||||
<div onClick={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{modal}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onWheelCapture={(event) => event.stopPropagation()}>
|
||||
{inputs.length ? (
|
||||
<div className="flex h-[min(66vh,580px)] flex-col gap-3 overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="图片提示词" count={imageInputs.length} empty="暂无图片提示词">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{imageInputs.map((input, index) => (
|
||||
<ImageSortCard key={input.nodeId} input={input} imageIndex={index} imageTotal={imageInputs.length} inputs={inputs} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="参考视频" count={videoInputs.length} empty="暂无参考视频">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{videoInputs.map((input, index) => (
|
||||
<VideoSortCard key={input.nodeId} input={input} videoIndex={index} videoTotal={videoInputs.length} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<PreviewSection title="参考音频" count={audioInputs.length} empty="暂无参考音频">
|
||||
<div className="thin-scrollbar flex gap-1.5 overflow-x-auto pb-1">
|
||||
{audioInputs.map((input, index) => (
|
||||
<AudioSortCard key={input.nodeId} input={input} audioIndex={index} audioTotal={audioInputs.length} theme={theme} onMove={moveInput} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="grid min-h-0 flex-1 grid-cols-2 gap-3 overflow-hidden">
|
||||
<div className="thin-scrollbar min-h-0 overflow-y-auto pr-1.5">
|
||||
<PreviewSection title="文本提示词" count={textInputs.length} empty="暂无文本提示词">
|
||||
<div className="space-y-1.5">
|
||||
{textInputs.map((input, index) => (
|
||||
<TextSortCard key={input.nodeId} input={input} textIndex={index} textTotal={textInputs.length} inputs={inputs} theme={theme} onMove={moveInput} onEdit={startTextEdit} />
|
||||
))}
|
||||
</div>
|
||||
</PreviewSection>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-col rounded-xl border p-2.5" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
{editingTextId ? (
|
||||
<>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">编辑文本提示词</div>
|
||||
<Button size="small" type="text" onClick={() => setEditingTextId(null)}>
|
||||
收起
|
||||
</Button>
|
||||
</div>
|
||||
<Input.TextArea className="thin-scrollbar !flex-1 !resize-none !text-xs !leading-5" value={editingText} onChange={(event) => setEditingText(event.target.value)} />
|
||||
<div className="mt-2 flex justify-end gap-2">
|
||||
<Button size="small" onClick={() => setEditingTextId(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="small" type="primary" onClick={saveTextEdit}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full flex-col justify-center rounded-xl border border-dashed px-4 text-center text-xs leading-5 opacity-45" style={{ borderColor: theme.node.stroke }}>
|
||||
<Edit3 className="mx-auto mb-2 size-5" />
|
||||
选择一条文本后在这里编辑
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无提示词或参考图" className="py-8" />
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSection({ title, count, empty, children }: { title: string; count: number; empty: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="sticky top-0 z-10 mb-1 flex items-center justify-between px-0.5 py-0.5 backdrop-blur-sm">
|
||||
<div className="text-xs font-semibold">{title}</div>
|
||||
<div className="text-[11px] opacity-50">{count} 个</div>
|
||||
</div>
|
||||
{count ? children : <div className="rounded-xl border border-dashed px-3 py-5 text-center text-xs opacity-45">{empty}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TextSortCard({
|
||||
input,
|
||||
textIndex,
|
||||
textTotal,
|
||||
inputs,
|
||||
theme,
|
||||
onMove,
|
||||
onEdit,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
textIndex: number;
|
||||
textTotal: number;
|
||||
inputs: NodeGenerationInput[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
onEdit: (input: NodeGenerationInput) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-1.5 rounded-md border px-2 py-1" style={{ background: `${theme.node.fill}99`, borderColor: theme.node.stroke }}>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[10px] font-medium opacity-50">文本 {textIndex + 1}</div>
|
||||
<div className="line-clamp-1 whitespace-pre-wrap break-words text-[11px] leading-4 opacity-80">{input.text}</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<Edit3 className="size-3" />} onClick={() => onEdit(input)} />
|
||||
<VerticalOrderButtons index={textIndex} total={textTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageSortCard({
|
||||
input,
|
||||
imageIndex,
|
||||
imageTotal,
|
||||
inputs,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
imageIndex: number;
|
||||
imageTotal: number;
|
||||
inputs: NodeGenerationInput[];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.image) return null;
|
||||
return (
|
||||
<div className="w-24 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative">
|
||||
<img src={input.image.dataUrl} alt={input.title} className="aspect-square w-full object-cover" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{imageReferenceLabel(imageIndex)}</span>
|
||||
<HorizontalOrderButtons index={imageIndex} total={imageTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VideoSortCard({
|
||||
input,
|
||||
videoIndex,
|
||||
videoTotal,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
videoIndex: number;
|
||||
videoTotal: number;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.video) return null;
|
||||
return (
|
||||
<div className="w-32 shrink-0 overflow-hidden rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="relative">
|
||||
<video src={input.video.url} className="aspect-video w-full bg-black object-cover" muted preload="metadata" />
|
||||
<span className="absolute left-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[9px] font-medium text-white">{seedanceReferenceLabel("video", videoIndex)}</span>
|
||||
<HorizontalOrderButtons index={videoIndex} total={videoTotal} onMove={(offset) => onMove(input, offset)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AudioSortCard({
|
||||
input,
|
||||
audioIndex,
|
||||
audioTotal,
|
||||
theme,
|
||||
onMove,
|
||||
}: {
|
||||
input: NodeGenerationInput;
|
||||
audioIndex: number;
|
||||
audioTotal: number;
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
onMove: (input: NodeGenerationInput, offset: number) => void;
|
||||
}) {
|
||||
if (!input.audio) return null;
|
||||
return (
|
||||
<div className="w-48 shrink-0 rounded-lg border p-2" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 text-[11px] opacity-70">
|
||||
<Music2 className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{input.title}</span>
|
||||
</div>
|
||||
<audio src={input.audio.url} controls className="h-8 w-full" preload="metadata" />
|
||||
<div className="mt-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowLeft className="size-3" />} disabled={audioIndex <= 0} onClick={() => onMove(input, -1)} />
|
||||
<span className="text-[10px] opacity-45">{seedanceReferenceLabel("audio", audioIndex)}</span>
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !p-0" icon={<ArrowRight className="size-3" />} disabled={audioIndex >= audioTotal - 1} onClick={() => onMove(input, 1)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VerticalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<>
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowUp className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !p-0" icon={<ArrowDown className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HorizontalOrderButtons({ index, total, onMove }: { index: number; total: number; onMove: (offset: number) => void }) {
|
||||
return (
|
||||
<div className="absolute inset-x-1 bottom-1 flex justify-between">
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowLeft className="size-3" />} disabled={index <= 0} onClick={() => onMove(-1)} />
|
||||
<Button size="small" className="!h-6 !w-6 !min-w-6 !rounded-full !bg-white/85 !p-0 !shadow-sm" icon={<ArrowRight className="size-3" />} disabled={index >= total - 1} onClick={() => onMove(1)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -395,17 +141,21 @@ function InputChip({ label, value, style }: { label: string; value: string; styl
|
||||
}
|
||||
|
||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasGenerationMode): AiConfig {
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
return {
|
||||
...globalConfig,
|
||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
|
||||
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
|
||||
count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -415,3 +165,10 @@ function videoConfigPatch(key: keyof AiConfig, value: string) {
|
||||
if (key === "videoWatermark") return { watermark: value };
|
||||
return { [key]: value };
|
||||
}
|
||||
|
||||
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
|
||||
if (key === "audioVoice") return { audioVoice: value };
|
||||
if (key === "audioFormat") return { audioFormat: value };
|
||||
if (key === "audioSpeed") return { audioSpeed: value };
|
||||
return { audioInstructions: value };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from "react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "../types";
|
||||
|
||||
export function ConnectionPath({ connection, from, to, active, onSelect }: { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData; active: boolean; onSelect: () => void }) {
|
||||
export function ConnectionPath({
|
||||
connection,
|
||||
from,
|
||||
to,
|
||||
active,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: {
|
||||
connection: CanvasConnection;
|
||||
from: CanvasNodeData;
|
||||
to: CanvasNodeData;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<SVGPathElement>) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const startX = from.position.x + from.width;
|
||||
const startY = from.position.y + from.height / 2;
|
||||
@@ -25,6 +41,11 @@ export function ConnectionPath({ connection, from, to, active, onSelect }: { con
|
||||
event.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onContextMenu?.(event);
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
@@ -38,7 +59,7 @@ export function ConnectionPath({ connection, from, to, active, onSelect }: { con
|
||||
);
|
||||
}
|
||||
|
||||
export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position }) {
|
||||
export function ActiveConnectionPath({ node, handle, mouseWorld, target }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position; target?: CanvasNodeData }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
if (!node) return null;
|
||||
|
||||
@@ -46,8 +67,12 @@ export function ActiveConnectionPath({ node, handle, mouseWorld }: { node?: Canv
|
||||
const startY = handle.handleType === "source" ? node.position.y + node.height / 2 : mouseWorld.y;
|
||||
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
|
||||
const endY = handle.handleType === "source" ? mouseWorld.y : node.position.y + node.height / 2;
|
||||
const distance = Math.abs(endX - startX);
|
||||
const pathD = `M ${startX} ${startY} C ${startX + distance * 0.5} ${startY}, ${endX - distance * 0.5} ${endY}, ${endX} ${endY}`;
|
||||
const snappedStartX = handle.handleType === "target" && target ? target.position.x + target.width : startX;
|
||||
const snappedStartY = handle.handleType === "target" && target ? target.position.y + target.height / 2 : startY;
|
||||
const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX;
|
||||
const snappedEndY = handle.handleType === "source" && target ? target.position.y + target.height / 2 : endY;
|
||||
const distance = Math.abs(snappedEndX - snappedStartX);
|
||||
const pathD = `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`;
|
||||
|
||||
return <path d={pathD} stroke={theme.node.activeStroke} strokeWidth="2" fill="none" strokeDasharray="5,5" />;
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export function CanvasNodeContextMenu({ menu, onClose, onDuplicate, onDelete }:
|
||||
style={{ left: menu.x, top: menu.y, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} />
|
||||
{menu.type === "node" ? <MenuButton icon={<Plus className="size-4" />} label="Duplicate" onClick={onDuplicate} /> : null}
|
||||
<MenuButton icon={<Trash2 className="size-4" />} label="Delete" onClick={onDelete} danger />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChang
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) return;
|
||||
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
|
||||
if (document.activeElement instanceof HTMLElement && panelRef.current?.contains(document.activeElement)) document.activeElement.blur();
|
||||
setOpen(false);
|
||||
onOpenChange?.(false);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { Button, Card, Checkbox, Form, Modal, Space, Switch, Tag, Tooltip, Typography, theme as antdTheme } from "antd";
|
||||
import { Ellipsis, Image as ImageIcon, Settings2 } from "lucide-react";
|
||||
|
||||
import type { ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||
|
||||
export type ImageToolbarSettingsTool = {
|
||||
id: ImageQuickToolId;
|
||||
title: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
type PreviewTool = ImageToolbarSettingsTool | {
|
||||
id: "more";
|
||||
title: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
type PreviewScroll = {
|
||||
left: number;
|
||||
max: number;
|
||||
viewport: number;
|
||||
content: number;
|
||||
};
|
||||
|
||||
export function ImageToolSettingsModal({
|
||||
open,
|
||||
tools,
|
||||
selectedIds,
|
||||
showLabels,
|
||||
onToggle,
|
||||
onShowLabelsChange,
|
||||
onCancel,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
tools: ImageToolbarSettingsTool[];
|
||||
selectedIds: ImageQuickToolId[];
|
||||
showLabels: boolean;
|
||||
onToggle: (id: ImageQuickToolId, visible: boolean) => void;
|
||||
onShowLabelsChange: (value: boolean) => void;
|
||||
onCancel: () => void;
|
||||
onSave: () => void;
|
||||
}) {
|
||||
const { token } = antdTheme.useToken();
|
||||
const previewToolbarRef = useRef<HTMLDivElement>(null);
|
||||
const scrollbarTrackRef = useRef<HTMLInputElement>(null);
|
||||
const [previewScroll, setPreviewScroll] = useState<PreviewScroll>({ left: 0, max: 0, viewport: 1, content: 1 });
|
||||
const selected = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
const selectedTools = tools.filter((tool) => selected.has(tool.id));
|
||||
const previewTools: PreviewTool[] = [
|
||||
...selectedTools,
|
||||
{ id: "more", title: "配置快捷工具", label: "更多", icon: <Ellipsis className="size-4" />, active: true },
|
||||
];
|
||||
|
||||
const syncPreviewScroll = useCallback(() => {
|
||||
const toolbar = previewToolbarRef.current;
|
||||
if (!toolbar) return;
|
||||
setPreviewScroll({
|
||||
left: toolbar.scrollLeft,
|
||||
max: Math.max(0, toolbar.scrollWidth - toolbar.clientWidth),
|
||||
viewport: Math.max(1, toolbar.clientWidth),
|
||||
content: Math.max(1, toolbar.scrollWidth),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setPreviewScrollLeft = useCallback(
|
||||
(left: number) => {
|
||||
const toolbar = previewToolbarRef.current;
|
||||
if (!toolbar) return;
|
||||
toolbar.scrollLeft = left;
|
||||
syncPreviewScroll();
|
||||
},
|
||||
[syncPreviewScroll],
|
||||
);
|
||||
|
||||
const updateSelectedTools = (values: ImageQuickToolId[]) => {
|
||||
const next = new Set(values);
|
||||
tools.forEach((tool) => {
|
||||
const visible = next.has(tool.id);
|
||||
if (selected.has(tool.id) !== visible) onToggle(tool.id, visible);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const toolbar = previewToolbarRef.current;
|
||||
const sync = () => syncPreviewScroll();
|
||||
const frames: number[] = [];
|
||||
const firstFrame = window.requestAnimationFrame(() => {
|
||||
sync();
|
||||
frames.push(window.requestAnimationFrame(sync));
|
||||
});
|
||||
frames.push(firstFrame);
|
||||
const timer = window.setTimeout(sync, 120);
|
||||
const resizeObserver = typeof ResizeObserver !== "undefined" && toolbar ? new ResizeObserver(sync) : null;
|
||||
resizeObserver?.observe(toolbar);
|
||||
toolbar?.childNodes.forEach((child) => {
|
||||
if (child instanceof Element) resizeObserver?.observe(child);
|
||||
});
|
||||
sync();
|
||||
window.addEventListener("resize", syncPreviewScroll);
|
||||
return () => {
|
||||
frames.forEach((frame) => window.cancelAnimationFrame(frame));
|
||||
window.clearTimeout(timer);
|
||||
resizeObserver?.disconnect();
|
||||
window.removeEventListener("resize", syncPreviewScroll);
|
||||
};
|
||||
}, [open, selectedIds, showLabels, previewTools.length, syncPreviewScroll]);
|
||||
|
||||
const scrollbarWidth = scrollbarTrackRef.current?.clientWidth || previewScroll.viewport;
|
||||
const scrollbarThumbWidth = previewScroll.max > 0 ? Math.min(scrollbarWidth, Math.max(64, (previewScroll.viewport / previewScroll.content) * scrollbarWidth)) : scrollbarWidth;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="自定义工具栏"
|
||||
open={open}
|
||||
centered
|
||||
width={760}
|
||||
onCancel={onCancel}
|
||||
destroyOnHidden
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>显示按钮文字</span>
|
||||
<Switch checked={showLabels} onChange={onShowLabelsChange} />
|
||||
</div>
|
||||
<Space>
|
||||
<Button onClick={onCancel}>取消</Button>
|
||||
<Button type="primary" onClick={onSave}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Typography.Paragraph type="secondary" className="!mb-4">
|
||||
选择你想在图片节点编辑栏中使用的快捷工具。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space size={6}>
|
||||
<Settings2 className="size-4" />
|
||||
节点预览
|
||||
</Space>
|
||||
}
|
||||
className="mb-4"
|
||||
>
|
||||
<div className="relative flex min-h-[300px] w-full justify-center pt-20 pb-9">
|
||||
<div
|
||||
ref={previewToolbarRef}
|
||||
className="hide-scrollbar absolute left-2 right-2 top-3 z-10 flex h-12 items-center overflow-x-auto rounded-[18px] border px-1 text-[13px]"
|
||||
style={{ background: token.colorBgElevated, borderColor: token.colorBorderSecondary, boxShadow: token.boxShadowSecondary, color: token.colorText }}
|
||||
onScroll={syncPreviewScroll}
|
||||
>
|
||||
{previewTools.map((tool) => (
|
||||
<PreviewToolbarItem key={tool.id} tool={tool} showLabels={showLabels} />
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="flex h-48 w-full max-w-[360px] flex-col items-center justify-center rounded-xl border"
|
||||
style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary, color: token.colorTextSecondary }}
|
||||
>
|
||||
<ImageIcon className="mb-2 size-8" />
|
||||
<Typography.Text type="secondary">图片节点</Typography.Text>
|
||||
</div>
|
||||
<input
|
||||
ref={scrollbarTrackRef}
|
||||
type="range"
|
||||
min={0}
|
||||
max={Math.max(previewScroll.max, 1)}
|
||||
value={Math.min(previewScroll.left, Math.max(previewScroll.max, 1))}
|
||||
disabled={previewScroll.max <= 0}
|
||||
className="absolute bottom-4 left-10 right-10 h-2.5 cursor-pointer appearance-none bg-transparent disabled:cursor-default [&::-moz-range-thumb]:h-2.5 [&::-moz-range-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-[#8d9498] [&::-moz-range-track]:h-2.5 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[#bdc4c8] [&::-webkit-slider-runnable-track]:h-2.5 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[#bdc4c8] [&::-webkit-slider-thumb]:h-2.5 [&::-webkit-slider-thumb]:w-[var(--preview-scrollbar-thumb-width)] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[#8d9498]"
|
||||
style={{ "--preview-scrollbar-thumb-width": `${scrollbarThumbWidth}px` } as CSSProperties}
|
||||
onInput={(event) => setPreviewScrollLeft(Number(event.currentTarget.value))}
|
||||
onChange={(event) => setPreviewScrollLeft(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Form layout="vertical" className="!mb-0">
|
||||
<Form.Item
|
||||
className="!mb-4"
|
||||
label={
|
||||
<Space size={8}>
|
||||
<span>快捷工具</span>
|
||||
<Tag className="m-0">
|
||||
{selectedTools.length}/{tools.length}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Checkbox.Group value={selectedIds} className="grid w-full gap-3 md:grid-cols-3" onChange={(values) => updateSelectedTools(values as ImageQuickToolId[])}>
|
||||
{tools.map((tool) => (
|
||||
<Checkbox key={tool.id} value={tool.id} className="m-0">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{tool.icon}
|
||||
{tool.label}
|
||||
</span>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbarItem({ tool, showLabels }: { tool: PreviewTool; showLabels: boolean }) {
|
||||
return (
|
||||
<Tooltip title={tool.title}>
|
||||
<span className="flex h-12 shrink-0 items-center px-1.5" style={{ color: tool.danger ? "#ef4444" : undefined }}>
|
||||
<span className={`flex h-9 items-center rounded-lg px-2 ${showLabels ? "gap-2" : "justify-center"}`}>
|
||||
{tool.icon}
|
||||
{showLabels ? <span className="whitespace-nowrap">{tool.label}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Brush, Camera, Copy, FileText, Grid2x2, Lock, LockOpen, Maximize2, Scissors, Sparkles, Upload, ZoomIn } from "lucide-react";
|
||||
|
||||
import type { CanvasNodeData } from "../types";
|
||||
|
||||
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
|
||||
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
|
||||
|
||||
export type ImageToolHandlers = {
|
||||
onUpload: (node: CanvasNodeData) => void;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onMaskEdit: (node: CanvasNodeData) => void;
|
||||
onCrop: (node: CanvasNodeData) => void;
|
||||
onSplit: (node: CanvasNodeData) => void;
|
||||
onUpscale: (node: CanvasNodeData) => void;
|
||||
onSuperResolve: (node: CanvasNodeData) => void;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onCopyPrompt: (node: CanvasNodeData) => void;
|
||||
onReversePrompt: (node: CanvasNodeData) => void;
|
||||
};
|
||||
|
||||
export type ImageToolDefinition = {
|
||||
id: ImageNodeActionToolId;
|
||||
defaultVisible: boolean;
|
||||
panelLabel: string;
|
||||
label: string | ((node: CanvasNodeData) => string);
|
||||
title: string | ((node: CanvasNodeData) => string);
|
||||
icon: (node: CanvasNodeData) => ReactNode;
|
||||
active?: (node: CanvasNodeData) => boolean;
|
||||
run: (node: CanvasNodeData, handlers: ImageToolHandlers) => void;
|
||||
};
|
||||
|
||||
export type ImageQuickToolsConfig = {
|
||||
ids: ImageQuickToolId[];
|
||||
showLabels: boolean;
|
||||
};
|
||||
|
||||
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v6";
|
||||
|
||||
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
|
||||
|
||||
export const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
{
|
||||
id: "copyPrompt",
|
||||
defaultVisible: true,
|
||||
panelLabel: "复制提示词",
|
||||
label: "复制提示词",
|
||||
title: "复制生成该图片的提示词",
|
||||
icon: () => <Copy className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCopyPrompt(node),
|
||||
},
|
||||
{
|
||||
id: "reversePrompt",
|
||||
defaultVisible: true,
|
||||
panelLabel: "反推提示词",
|
||||
label: "反推提示词",
|
||||
title: "创建反推提示词的文本和配置节点",
|
||||
icon: () => <FileText className="size-4" />,
|
||||
run: (node, handlers) => handlers.onReversePrompt(node),
|
||||
},
|
||||
{
|
||||
id: "replace",
|
||||
defaultVisible: true,
|
||||
panelLabel: "替换图片",
|
||||
label: "替换图片",
|
||||
title: "替换图片",
|
||||
icon: () => <Upload className="size-4" />,
|
||||
run: (node, handlers) => handlers.onUpload(node),
|
||||
},
|
||||
{
|
||||
id: "resize",
|
||||
defaultVisible: false,
|
||||
panelLabel: "锁比例",
|
||||
label: (node) => (node.metadata?.freeResize ? "自由比例" : "锁比例"),
|
||||
title: (node) => (node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"),
|
||||
icon: (node) => (node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />),
|
||||
active: (node) => Boolean(node.metadata?.freeResize),
|
||||
run: (node, handlers) => handlers.onToggleFreeResize(node),
|
||||
},
|
||||
{
|
||||
id: "maskEdit",
|
||||
defaultVisible: true,
|
||||
panelLabel: "局部编辑",
|
||||
label: "局部编辑",
|
||||
title: "添加蒙版遮罩后局部修改",
|
||||
icon: () => <Brush className="size-4" />,
|
||||
run: (node, handlers) => handlers.onMaskEdit(node),
|
||||
},
|
||||
{
|
||||
id: "crop",
|
||||
defaultVisible: true,
|
||||
panelLabel: "裁剪",
|
||||
label: "裁剪",
|
||||
title: "裁剪并生成新节点",
|
||||
icon: () => <Scissors className="size-4" />,
|
||||
run: (node, handlers) => handlers.onCrop(node),
|
||||
},
|
||||
{
|
||||
id: "split",
|
||||
defaultVisible: true,
|
||||
panelLabel: "切图",
|
||||
label: "切图",
|
||||
title: "按行列切分图片",
|
||||
icon: () => <Grid2x2 className="size-4" />,
|
||||
run: (node, handlers) => handlers.onSplit(node),
|
||||
},
|
||||
{
|
||||
id: "upscale",
|
||||
defaultVisible: true,
|
||||
panelLabel: "放大",
|
||||
label: "放大",
|
||||
title: "放大图片分辨率",
|
||||
icon: () => <ZoomIn className="size-4" />,
|
||||
run: (node, handlers) => handlers.onUpscale(node),
|
||||
},
|
||||
{
|
||||
id: "superResolve",
|
||||
defaultVisible: false,
|
||||
panelLabel: "超分",
|
||||
label: "超分",
|
||||
title: "AI 超分",
|
||||
icon: () => <Sparkles className="size-4" />,
|
||||
run: (node, handlers) => handlers.onSuperResolve(node),
|
||||
},
|
||||
{
|
||||
id: "angle",
|
||||
defaultVisible: false,
|
||||
panelLabel: "多角度",
|
||||
label: "多角度",
|
||||
title: "生成角度",
|
||||
icon: () => <Camera className="size-4" />,
|
||||
run: (node, handlers) => handlers.onAngle(node),
|
||||
},
|
||||
{
|
||||
id: "view",
|
||||
defaultVisible: true,
|
||||
panelLabel: "查看大图",
|
||||
label: "查看大图",
|
||||
title: "查看图片详情",
|
||||
icon: () => <Maximize2 className="size-4" />,
|
||||
run: (node, handlers) => handlers.onViewImage(node),
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultImageQuickToolIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.filter((tool) => tool.defaultVisible).map((tool) => tool.id)];
|
||||
|
||||
export function buildImageToolbarTools(node: CanvasNodeData, handlers: ImageToolHandlers) {
|
||||
return imageToolDefinitions.map((tool) => ({
|
||||
id: tool.id,
|
||||
label: resolveToolText(tool.label, node),
|
||||
title: resolveToolText(tool.title, node),
|
||||
icon: tool.icon(node),
|
||||
active: tool.active?.(node),
|
||||
onClick: () => tool.run(node, handlers),
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeImageQuickToolIds(value: unknown[]) {
|
||||
const allIds: ImageQuickToolId[] = [...defaultBaseToolIds, ...imageToolDefinitions.map((tool) => tool.id)];
|
||||
const ids = new Set(allIds);
|
||||
return allIds.filter((id) => value.includes(id) && ids.has(id));
|
||||
}
|
||||
|
||||
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig {
|
||||
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true };
|
||||
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true };
|
||||
const data = value as Partial<ImageQuickToolsConfig>;
|
||||
return {
|
||||
ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds,
|
||||
showLabels: data.showLabels !== false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveToolText(value: string | ((node: CanvasNodeData) => string), node: CanvasNodeData) {
|
||||
return typeof value === "function" ? value(node) : value;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import type { ChatCompletionMessage } from "@/services/api/image";
|
||||
import { imageReferenceLabel } from "@/lib/image-reference-prompt";
|
||||
import { seedanceReferenceLabel } from "@/lib/seedance-video";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData } from "../types";
|
||||
import { getGenerationResourceNodes } from "../utils/canvas-resource-references";
|
||||
|
||||
export type NodeGenerationContext = {
|
||||
prompt: string;
|
||||
@@ -26,6 +29,11 @@ export type NodeGenerationInput = {
|
||||
|
||||
export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[], prompt: string): NodeGenerationContext {
|
||||
const inputs = buildNodeGenerationInputs(nodeId, nodes, connections);
|
||||
const sourceNode = nodes.find((node) => node.id === nodeId);
|
||||
if (sourceNode?.type === CanvasNodeType.Config && Boolean(sourceNode.metadata?.composerContent?.trim())) {
|
||||
return buildComposerGenerationContext(inputs, prompt);
|
||||
}
|
||||
|
||||
const upstreamText = inputs
|
||||
.map((input) => input.text)
|
||||
.filter(Boolean)
|
||||
@@ -46,8 +54,67 @@ export function buildNodeGenerationContext(nodeId: string, nodes: CanvasNodeData
|
||||
};
|
||||
}
|
||||
|
||||
function buildComposerGenerationContext(inputs: NodeGenerationInput[], prompt: string): NodeGenerationContext {
|
||||
const inputByNodeId = new Map(inputs.map((input) => [input.nodeId, input]));
|
||||
const selectedInputs: NodeGenerationInput[] = [];
|
||||
const labelByNodeId = new Map<string, string>();
|
||||
const textBlocks: string[] = [];
|
||||
const counts = { image: 0, video: 0, audio: 0, text: 0 };
|
||||
let hasToken = false;
|
||||
let lastIndex = 0;
|
||||
let nextPrompt = "";
|
||||
|
||||
for (const match of prompt.matchAll(/@\[node:([^\]]+)\]/g)) {
|
||||
if (match.index === undefined) continue;
|
||||
hasToken = true;
|
||||
nextPrompt += prompt.slice(lastIndex, match.index);
|
||||
const input = inputByNodeId.get(match[1]);
|
||||
if (input) {
|
||||
let label = labelByNodeId.get(input.nodeId);
|
||||
if (!label) {
|
||||
label = generationLabel(input.type, counts[input.type]++);
|
||||
labelByNodeId.set(input.nodeId, label);
|
||||
if (input.type === "text") textBlocks.push(`【${label}】\n${input.text || ""}`);
|
||||
else selectedInputs.push(input);
|
||||
}
|
||||
nextPrompt += input.type === "text" ? `【${label}】` : label;
|
||||
}
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
nextPrompt += prompt.slice(lastIndex);
|
||||
if (textBlocks.length) nextPrompt = `${nextPrompt.trim()}\n\n${textBlocks.join("\n\n")}`;
|
||||
const referenceImages = selectedInputs.map((input) => input.image).filter((image): image is ReferenceImage => Boolean(image));
|
||||
const referenceVideos = selectedInputs.map((input) => input.video).filter((video): video is ReferenceVideo => Boolean(video));
|
||||
const referenceAudios = selectedInputs.map((input) => input.audio).filter((audio): audio is ReferenceAudio => Boolean(audio));
|
||||
|
||||
if (!hasToken) {
|
||||
return {
|
||||
prompt,
|
||||
referenceImages: [],
|
||||
referenceVideos: [],
|
||||
referenceAudios: [],
|
||||
textCount: 0,
|
||||
imageCount: 0,
|
||||
videoCount: 0,
|
||||
audioCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prompt: nextPrompt,
|
||||
referenceImages,
|
||||
referenceVideos,
|
||||
referenceAudios,
|
||||
textCount: counts.text,
|
||||
imageCount: referenceImages.length,
|
||||
videoCount: referenceVideos.length,
|
||||
audioCount: referenceAudios.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildNodeGenerationInputs(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]): NodeGenerationInput[] {
|
||||
return getOrderedUpstreamNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||
return getGenerationResourceNodes(nodeId, nodes, connections).flatMap((node): NodeGenerationInput[] => {
|
||||
const image = readReferenceImage(node);
|
||||
if (image) return [{ nodeId: node.id, type: "image" as const, title: node.title, image }];
|
||||
const video = readReferenceVideo(node);
|
||||
@@ -83,6 +150,13 @@ function readNodeTextInput(node: CanvasNodeData) {
|
||||
return node.metadata?.prompt || "";
|
||||
}
|
||||
|
||||
function generationLabel(type: NodeGenerationInput["type"], index: number) {
|
||||
if (type === "image") return imageReferenceLabel(index);
|
||||
if (type === "video") return seedanceReferenceLabel("video", index);
|
||||
if (type === "audio") return seedanceReferenceLabel("audio", index);
|
||||
return `文本${index + 1}`;
|
||||
}
|
||||
|
||||
function readReferenceImage(node: CanvasNodeData): ReferenceImage | null {
|
||||
if (node.type !== CanvasNodeType.Image || !node.metadata?.content) return null;
|
||||
return {
|
||||
@@ -120,13 +194,3 @@ function readReferenceAudio(node: CanvasNodeData): ReferenceAudio | null {
|
||||
durationMs: node.metadata.durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
function getOrderedUpstreamNodes(nodeId: string, nodes: CanvasNodeData[], connections: CanvasConnection[]) {
|
||||
const target = nodes.find((node) => node.id === nodeId);
|
||||
const upstreamNodes = connections
|
||||
.filter((connection) => connection.toNodeId === nodeId)
|
||||
.map((connection) => nodes.find((node) => node.id === connection.fromNodeId))
|
||||
.filter((node): node is CanvasNodeData => Boolean(node));
|
||||
const order = target?.metadata?.inputOrder || [];
|
||||
return [...order.map((id) => upstreamNodes.find((node) => node.id === id)).filter((node): node is CanvasNodeData => Boolean(node)), ...upstreamNodes.filter((node) => !order.includes(node.id))];
|
||||
}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Modal, Segmented, Tooltip } from "antd";
|
||||
import { Camera, Download, FolderPlus, Image as ImageIcon, Info, Lock, LockOpen, Maximize2, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Scissors, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
import { App, Modal, Segmented, Tooltip } from "antd";
|
||||
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { useCopyText } from "@/hooks/use-copy-text";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasNodeType, type CanvasNodeData, type ViewportTransform } from "../types";
|
||||
import { ImageToolSettingsModal, type ImageToolbarSettingsTool } from "./canvas-image-toolbar-settings-modal";
|
||||
import { IMAGE_QUICK_TOOLS_STORAGE_KEY, buildImageToolbarTools, defaultImageQuickToolIds, readImageQuickToolsConfig, type ImageQuickToolId } from "./canvas-image-toolbar-tools";
|
||||
|
||||
type CanvasNodeHoverToolbarProps = {
|
||||
node: CanvasNodeData | null;
|
||||
@@ -23,14 +26,29 @@ type CanvasNodeHoverToolbarProps = {
|
||||
onUpload: (node: CanvasNodeData) => void;
|
||||
onDownload: (node: CanvasNodeData) => void;
|
||||
onSaveAsset: (node: CanvasNodeData) => void;
|
||||
onMaskEdit: (node: CanvasNodeData) => void;
|
||||
onCrop: (node: CanvasNodeData) => void;
|
||||
onSplit: (node: CanvasNodeData) => void;
|
||||
onUpscale: (node: CanvasNodeData) => void;
|
||||
onSuperResolve: (node: CanvasNodeData) => void;
|
||||
onAngle: (node: CanvasNodeData) => void;
|
||||
onViewImage: (node: CanvasNodeData) => void;
|
||||
onReversePrompt: (node: CanvasNodeData) => void;
|
||||
onRetry: (node: CanvasNodeData) => void;
|
||||
onToggleFreeResize: (node: CanvasNodeData) => void;
|
||||
onDelete: (node: CanvasNodeData) => void;
|
||||
};
|
||||
|
||||
type ToolbarTool = {
|
||||
id: string;
|
||||
title: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
};
|
||||
|
||||
export function CanvasNodeHoverToolbar({
|
||||
node,
|
||||
viewport,
|
||||
@@ -45,13 +63,43 @@ export function CanvasNodeHoverToolbar({
|
||||
onUpload,
|
||||
onDownload,
|
||||
onSaveAsset,
|
||||
onMaskEdit,
|
||||
onCrop,
|
||||
onSplit,
|
||||
onUpscale,
|
||||
onSuperResolve,
|
||||
onAngle,
|
||||
onViewImage,
|
||||
onReversePrompt,
|
||||
onRetry,
|
||||
onToggleFreeResize,
|
||||
onDelete,
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
|
||||
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const copyText = useCopyText();
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = window.localStorage.getItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
|
||||
if (!stored) return;
|
||||
const parsed = JSON.parse(stored) as unknown;
|
||||
const config = readImageQuickToolsConfig(parsed);
|
||||
setQuickImageToolIds(config.ids);
|
||||
setShowImageToolLabels(config.showLabels);
|
||||
} catch {
|
||||
window.localStorage.removeItem(IMAGE_QUICK_TOOLS_STORAGE_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setImageToolSettingsOpen(false);
|
||||
}, [node?.id]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const left = viewport.x + (node.position.x + node.width / 2) * viewport.k;
|
||||
@@ -66,45 +114,98 @@ export function CanvasNodeHoverToolbar({
|
||||
const isConfig = node.type === CanvasNodeType.Config;
|
||||
const canOpenDialog = isText || hasImage || isVideo;
|
||||
const canRetry = node.metadata?.status === "error";
|
||||
const hasSpecificTools = canRetry || isText || isImage || isVideo || isAudio || isConfig;
|
||||
const quickImageToolIdSet = new Set(quickImageToolIds);
|
||||
const copyImagePrompt = (target: CanvasNodeData) => {
|
||||
const prompt = target.metadata?.prompt?.trim();
|
||||
if (!prompt) {
|
||||
message.warning("暂无可复制的提示词");
|
||||
return;
|
||||
}
|
||||
copyText(prompt, "提示词已复制");
|
||||
};
|
||||
const imageTools = buildImageToolbarTools(node, { onUpload, onToggleFreeResize, onMaskEdit, onCrop, onSplit, onUpscale, onSuperResolve, onAngle, onViewImage, onCopyPrompt: copyImagePrompt, onReversePrompt });
|
||||
|
||||
function openImageToolSettings() {
|
||||
onKeep(node.id);
|
||||
setDraftImageToolIds(quickImageToolIds);
|
||||
setDraftShowImageToolLabels(showImageToolLabels);
|
||||
setImageToolSettingsOpen(true);
|
||||
}
|
||||
|
||||
const baseToolbarTools: ToolbarTool[] = [
|
||||
{ id: "info", title: "查看节点信息", label: "信息", icon: <Info className="size-4" />, onClick: () => onInfo(node) },
|
||||
{ id: "delete", title: "移除节点", label: "删除", icon: <Trash2 className="size-4" />, onClick: () => onDelete(node), danger: true },
|
||||
];
|
||||
const nodeToolbarTools: ToolbarTool[] = [
|
||||
...(canRetry ? [{ id: "retry", title: "重新生成", label: "重试", icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
||||
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: "加入我的素材", label: "存素材", icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片", label: "下载", icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||
...(canOpenDialog ? [{ id: "edit", title: "编辑", label: "编辑", icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "editText", title: "编辑文本", label: "编辑文字", icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||
...(isText ? [{ id: "generateImage", title: "用文本生图", label: "生图", icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
|
||||
...(isConfig ? [{ id: "config", title: "生成配置", label: "生成配置", icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "decreaseFont", title: "减小字号", label: "缩小", icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
|
||||
...(isText ? [{ id: "increaseFont", title: "增大字号", label: "放大", icon: <Plus className="size-4" />, onClick: () => onIncreaseFont(node) }] : []),
|
||||
...(isImage && !hasImage ? [{ id: "uploadImage", title: "上传图片", label: "上传图片", icon: <Upload className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(isVideo ? [{ id: "uploadVideo", title: hasVideo ? "替换视频" : "上传视频", label: hasVideo ? "替换视频" : "上传视频", icon: <Video className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(isAudio ? [{ id: "uploadAudio", title: hasAudio ? "替换音频" : "上传音频", label: hasAudio ? "替换音频" : "上传音频", icon: <Music2 className="size-4" />, onClick: () => onUpload(node) }] : []),
|
||||
...(hasImage ? imageTools.map((tool) => ({ id: tool.id, title: tool.title, label: tool.label, icon: tool.icon, active: tool.active, onClick: tool.onClick })) : []),
|
||||
];
|
||||
const toolbarTools = hasImage ? [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => quickImageToolIdSet.has(tool.id as ImageQuickToolId)) : [...baseToolbarTools, ...nodeToolbarTools];
|
||||
const selectableImageToolbarTools = [...baseToolbarTools, ...nodeToolbarTools].filter((tool) => tool.id !== "retry") as ImageToolbarSettingsTool[];
|
||||
|
||||
const closeImageToolSettings = () => {
|
||||
setImageToolSettingsOpen(false);
|
||||
onLeave();
|
||||
};
|
||||
|
||||
const setDraftImageToolVisible = (id: ImageQuickToolId, visible: boolean) => {
|
||||
setDraftImageToolIds((current) => {
|
||||
const selected = new Set(current);
|
||||
if (visible) selected.add(id);
|
||||
else selected.delete(id);
|
||||
return selectableImageToolbarTools.filter((tool) => selected.has(tool.id)).map((tool) => tool.id);
|
||||
});
|
||||
};
|
||||
|
||||
const saveImageToolSettings = () => {
|
||||
const config = { ids: draftImageToolIds, showLabels: draftShowImageToolLabels };
|
||||
setQuickImageToolIds(config.ids);
|
||||
setShowImageToolLabels(config.showLabels);
|
||||
window.localStorage.setItem(IMAGE_QUICK_TOOLS_STORAGE_KEY, JSON.stringify(config));
|
||||
closeImageToolSettings();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
||||
style={{ left, top }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={onLeave}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<ToolbarAction title="查看节点信息" label="信息" icon={<Info className="size-4" />} onClick={() => onInfo(node)} />
|
||||
<ToolbarAction title="移除节点" label="删除" icon={<Trash2 className="size-4" />} onClick={() => onDelete(node)} danger />
|
||||
{hasSpecificTools ? <ToolbarDivider /> : null}
|
||||
{canRetry ? <ToolbarAction title="重新生成" label="重试" icon={<RefreshCw className="size-4" />} onClick={() => onRetry(node)} /> : null}
|
||||
{hasImage || hasVideo || isText ? <ToolbarAction title="加入我的素材" label="存素材" icon={<FolderPlus className="size-4" />} onClick={() => onSaveAsset(node)} /> : null}
|
||||
{hasImage || hasVideo || hasAudio ? <IconAction title={hasAudio ? "下载音频" : hasVideo ? "下载视频" : "下载图片"} icon={<Download className="size-5" />} onClick={() => onDownload(node)} /> : null}
|
||||
{canOpenDialog ? <ToolbarAction title="编辑" label="编辑" icon={<MessageSquare className="size-4" />} onClick={() => onToggleDialog(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="编辑文本" label="编辑文字" icon={<Pencil className="size-4" />} onClick={() => onEditText(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="用文本生图" label="生图" icon={<ImageIcon className="size-4" />} onClick={() => onGenerateImage(node)} /> : null}
|
||||
{isConfig ? <ToolbarAction title="生成配置" label="生成配置" icon={<Settings2 className="size-4" />} onClick={() => onInfo(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="减小字号" label="缩小" icon={<Minus className="size-4" />} onClick={() => onDecreaseFont(node)} /> : null}
|
||||
{isText ? <ToolbarAction title="增大字号" label="放大" icon={<Plus className="size-4" />} onClick={() => onIncreaseFont(node)} /> : null}
|
||||
{isImage ? <ToolbarAction title={hasImage ? "替换图片" : "上传图片"} label={hasImage ? "替换图片" : "上传图片"} icon={<Upload className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{isVideo ? <ToolbarAction title={hasVideo ? "替换视频" : "上传视频"} label={hasVideo ? "替换视频" : "上传视频"} icon={<Video className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
{isAudio ? <ToolbarAction title={hasAudio ? "替换音频" : "上传音频"} label={hasAudio ? "替换音频" : "上传音频"} icon={<Music2 className="size-4" />} onClick={() => onUpload(node)} /> : null}
|
||||
<>
|
||||
<div
|
||||
className="absolute z-[70] flex h-12 -translate-x-1/2 -translate-y-full items-center overflow-visible rounded-[18px] border border-black/10 bg-white text-[15px] text-[#242529] shadow-[0_8px_28px_rgba(15,23,42,.12)]"
|
||||
style={{ left, top }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={() => {
|
||||
if (!imageToolSettingsOpen) onLeave();
|
||||
}}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{toolbarTools.map((tool) => (
|
||||
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
|
||||
))}
|
||||
{hasImage ? <ToolbarAction id="more" title="配置快捷工具" label="更多" icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
|
||||
</div>
|
||||
{hasImage ? (
|
||||
<ToolbarAction
|
||||
title={node.metadata?.freeResize ? "切换为等比缩放" : "切换为自由比例"}
|
||||
label={node.metadata?.freeResize ? "自由比例" : "锁比例"}
|
||||
icon={node.metadata?.freeResize ? <LockOpen className="size-4" /> : <Lock className="size-4" />}
|
||||
onClick={() => onToggleFreeResize(node)}
|
||||
active={node.metadata?.freeResize}
|
||||
<ImageToolSettingsModal
|
||||
open={imageToolSettingsOpen}
|
||||
tools={selectableImageToolbarTools}
|
||||
selectedIds={draftImageToolIds}
|
||||
showLabels={draftShowImageToolLabels}
|
||||
onToggle={setDraftImageToolVisible}
|
||||
onShowLabelsChange={setDraftShowImageToolLabels}
|
||||
onCancel={closeImageToolSettings}
|
||||
onSave={saveImageToolSettings}
|
||||
/>
|
||||
) : null}
|
||||
{hasImage ? <ToolbarAction title="裁剪并生成新节点" label="裁剪" icon={<Scissors className="size-4" />} onClick={() => onCrop(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="生成角度" label="多角度" icon={<Camera className="size-4" />} onClick={() => onAngle(node)} /> : null}
|
||||
{hasImage ? <ToolbarAction title="查看图片详情" label="查看大图" icon={<Maximize2 className="size-4" />} onClick={() => onViewImage(node)} /> : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -178,34 +279,20 @@ export function CanvasNodeInfoModal({ node, open, onClose }: { node: CanvasNodeD
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarAction({ title, label, icon, onClick, hint, active = false, danger = false }: { title: string; label: string; icon: ReactNode; onClick?: () => void; hint?: string; active?: boolean; danger?: boolean }) {
|
||||
function ToolbarAction({ title, label, icon, onClick, showLabel, active = false, danger = false }: ToolbarTool & { showLabel: boolean }) {
|
||||
const hasText = showLabel && Boolean(label);
|
||||
return (
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2} color="#ffffff" styles={{ body: { color: "#242529", boxShadow: "0 8px 24px rgba(15,23,42,.16)", fontSize: 13, fontWeight: 500 } }}>
|
||||
<button type="button" className={`group relative flex h-12 items-center whitespace-nowrap px-1.5 ${danger ? "text-[#ef4444]" : ""}`} onClick={onClick} aria-label={title}>
|
||||
<span className={`flex h-9 items-center gap-2 rounded-lg px-2.5 transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
||||
<span className={`flex h-9 items-center ${hasText ? "gap-2 px-2.5" : "justify-center px-2"} rounded-lg transition group-hover:bg-[#f0f0f1] ${active ? "bg-[#eeeeef]" : ""}`}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{hint ? <span className="text-[#a3a3a3]">{hint}</span> : null}
|
||||
{hasText ? <span>{label}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAction({ title, icon, onClick }: { title: string; icon: ReactNode; onClick: () => void }) {
|
||||
return (
|
||||
<Tooltip title={title} placement="top" mouseEnterDelay={0.2}>
|
||||
<button type="button" className="group relative grid h-12 w-12 place-items-center px-1.5" onClick={onClick} aria-label={title}>
|
||||
<span className="grid size-9 place-items-center rounded-lg transition group-hover:bg-[#f0f0f1]">{icon}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolbarDivider() {
|
||||
return <span className="mx-1 h-7 w-px scale-x-50 bg-[#dedee2]" />;
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[72px_minmax(0,1fr)] gap-3">
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { Button, Input, Modal, Slider } from "antd";
|
||||
import { Brush, Eraser, RotateCcw, WandSparkles, X } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
|
||||
export type CanvasImageMaskEditPayload = {
|
||||
prompt: string;
|
||||
maskDataUrl: string;
|
||||
};
|
||||
|
||||
type DrawMode = "paint" | "erase";
|
||||
|
||||
const defaultBrushSize = 100;
|
||||
const maskFillColor = "rgba(37, 99, 235, .38)";
|
||||
const maskBorderColor = "rgba(255, 255, 255, .72)";
|
||||
|
||||
export function CanvasNodeMaskEditDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (payload: CanvasImageMaskEditPayload) => void }) {
|
||||
const maskCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawingRef = useRef<{ active: boolean; last: { x: number; y: number } | null }>({ active: false, last: null });
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [brushSize, setBrushSize] = useState(defaultBrushSize);
|
||||
const [mode, setMode] = useState<DrawMode>("paint");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setPrompt("");
|
||||
setBrushSize(defaultBrushSize);
|
||||
setMode("paint");
|
||||
setError("");
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
clearCanvas(maskCanvasRef.current);
|
||||
clearCanvas(previewCanvasRef.current);
|
||||
}, [image]);
|
||||
|
||||
const draw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
const point = readCanvasPoint(event.currentTarget, event.clientX, event.clientY);
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
const context = maskCanvas?.getContext("2d");
|
||||
if (!context) return;
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.lineWidth = brushSize;
|
||||
context.globalCompositeOperation = mode === "paint" ? "source-over" : "destination-out";
|
||||
context.strokeStyle = "#000";
|
||||
context.fillStyle = "#000";
|
||||
if (!drawingRef.current.last) {
|
||||
drawMaskStroke(context, point, point, brushSize);
|
||||
} else {
|
||||
drawMaskStroke(context, drawingRef.current.last, point, brushSize);
|
||||
}
|
||||
renderMaskPreview(maskCanvas, previewCanvasRef.current);
|
||||
drawingRef.current.last = point;
|
||||
if (mode === "paint") {
|
||||
setError("");
|
||||
}
|
||||
};
|
||||
|
||||
const startDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
drawingRef.current = { active: true, last: null };
|
||||
if (maskCanvasRef.current) renderMaskPreview(maskCanvasRef.current, previewCanvasRef.current);
|
||||
draw(event);
|
||||
};
|
||||
|
||||
const moveDraw = (event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawingRef.current.active) return;
|
||||
event.preventDefault();
|
||||
draw(event);
|
||||
};
|
||||
|
||||
const stopDraw = () => {
|
||||
drawingRef.current = { active: false, last: null };
|
||||
const maskCanvas = maskCanvasRef.current;
|
||||
if (maskCanvas) renderMaskPreview(maskCanvas, previewCanvasRef.current, canvasHasPaint(maskCanvas));
|
||||
};
|
||||
|
||||
const resetMask = () => {
|
||||
clearCanvas(maskCanvasRef.current);
|
||||
clearCanvas(previewCanvasRef.current);
|
||||
setError("");
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const nextPrompt = prompt.trim();
|
||||
const canvas = maskCanvasRef.current;
|
||||
if (!nextPrompt) return setError("请输入修改要求");
|
||||
if (!canvas) return;
|
||||
if (!canvasHasPaint(canvas)) return setError("请先涂抹局部区域");
|
||||
onConfirm({ prompt: nextPrompt, maskDataUrl: buildEditMask(canvas) });
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={980} centered destroyOnHidden>
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(360px,1fr)_320px]">
|
||||
<div className="flex min-h-[360px] items-center justify-center rounded-xl border border-black/10 bg-transparent p-0 dark:border-white/10">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-transparent select-none">
|
||||
<img src={dataUrl} alt="" className="block max-h-[68vh] max-w-full bg-transparent" draggable={false} />
|
||||
{image ? (
|
||||
<>
|
||||
<canvas ref={maskCanvasRef} width={image.width} height={image.height} className="hidden" />
|
||||
<canvas
|
||||
ref={previewCanvasRef}
|
||||
width={image.width}
|
||||
height={image.height}
|
||||
className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
|
||||
onPointerDown={startDraw}
|
||||
onPointerMove={moveDraw}
|
||||
onPointerUp={stopDraw}
|
||||
onPointerCancel={stopDraw}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-[360px] flex-col gap-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">局部遮罩编辑</h2>
|
||||
<div className="mt-2 text-sm opacity-60">{image ? `${image.width} x ${image.height}px` : "读取中"}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type={mode === "paint" ? "primary" : "default"} icon={<Brush className="size-4" />} onClick={() => setMode("paint")}>
|
||||
画笔
|
||||
</Button>
|
||||
<Button type={mode === "erase" ? "primary" : "default"} icon={<Eraser className="size-4" />} onClick={() => setMode("erase")}>
|
||||
擦除
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium opacity-75">笔刷大小</span>
|
||||
<span className="font-semibold">{brushSize}px</span>
|
||||
</div>
|
||||
<Slider min={8} max={160} step={2} value={brushSize} onChange={setBrushSize} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium opacity-75">修改要求</div>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={prompt}
|
||||
status={error && !prompt.trim() ? "error" : undefined}
|
||||
placeholder="例如:把选中区域改成金属材质,保持原图光影"
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
/>
|
||||
{error ? <div className="text-xs font-medium text-[#ef4444]">{error}</div> : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-2">
|
||||
<Button icon={<RotateCcw className="size-4" />} onClick={resetMask}>
|
||||
重置
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button icon={<X className="size-4" />} onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="primary" icon={<WandSparkles className="size-4" />} onClick={submit}>
|
||||
AI 修改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function readCanvasPoint(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: ((clientX - rect.left) / Math.max(1, rect.width)) * canvas.width,
|
||||
y: ((clientY - rect.top) / Math.max(1, rect.height)) * canvas.height,
|
||||
};
|
||||
}
|
||||
|
||||
function clearCanvas(canvas: HTMLCanvasElement | null) {
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function drawMaskStroke(context: CanvasRenderingContext2D, from: { x: number; y: number }, to: { x: number; y: number }, size: number) {
|
||||
if (from.x === to.x && from.y === to.y) {
|
||||
context.beginPath();
|
||||
context.arc(to.x, to.y, size / 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
return;
|
||||
}
|
||||
context.beginPath();
|
||||
context.moveTo(from.x, from.y);
|
||||
context.lineTo(to.x, to.y);
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function canvasHasPaint(canvas: HTMLCanvasElement) {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return false;
|
||||
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
for (let index = 3; index < data.length; index += 4) {
|
||||
if (data[index] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderMaskPreview(maskCanvas: HTMLCanvasElement, previewCanvas: HTMLCanvasElement | null, withBorder = false) {
|
||||
const context = previewCanvas?.getContext("2d");
|
||||
if (!previewCanvas || !context) return;
|
||||
context.clearRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||
context.fillStyle = maskFillColor;
|
||||
context.fillRect(0, 0, previewCanvas.width, previewCanvas.height);
|
||||
context.globalCompositeOperation = "destination-in";
|
||||
context.drawImage(maskCanvas, 0, 0);
|
||||
context.globalCompositeOperation = "source-over";
|
||||
if (withBorder) drawDashedMaskBorder(context, maskCanvas);
|
||||
}
|
||||
|
||||
function drawDashedMaskBorder(context: CanvasRenderingContext2D, maskCanvas: HTMLCanvasElement) {
|
||||
const maskContext = maskCanvas.getContext("2d");
|
||||
if (!maskContext) return;
|
||||
const { width, height } = maskCanvas;
|
||||
const data = maskContext.getImageData(0, 0, width, height).data;
|
||||
const step = Math.max(1, Math.round(Math.max(width, height) / 1200));
|
||||
const dash = step * 8;
|
||||
const gap = step * 5;
|
||||
const period = dash + gap;
|
||||
|
||||
context.save();
|
||||
context.fillStyle = maskBorderColor;
|
||||
context.shadowColor = "rgba(0, 0, 0, .24)";
|
||||
context.shadowBlur = step * 1.5;
|
||||
for (let y = step; y < height - step; y += step) {
|
||||
for (let x = step; x < width - step; x += step) {
|
||||
const offset = (y * width + x) * 4 + 3;
|
||||
if (data[offset] === 0 || !isMaskEdge(data, width, x, y, step)) continue;
|
||||
if ((x + y) % period > dash) continue;
|
||||
context.fillRect(x - step / 2, y - step / 2, Math.max(1.5, step), Math.max(1.5, step));
|
||||
}
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function isMaskEdge(data: Uint8ClampedArray, width: number, x: number, y: number, step: number) {
|
||||
return data[((y - step) * width + x) * 4 + 3] === 0 || data[((y + step) * width + x) * 4 + 3] === 0 || data[(y * width + x - step) * 4 + 3] === 0 || data[(y * width + x + step) * 4 + 3] === 0;
|
||||
}
|
||||
|
||||
function buildEditMask(selectionCanvas: HTMLCanvasElement) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = selectionCanvas.width;
|
||||
canvas.height = selectionCanvas.height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return selectionCanvas.toDataURL("image/png");
|
||||
const selectionContext = selectionCanvas.getContext("2d");
|
||||
context.fillStyle = "#fff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
if (!selectionContext) return canvas.toDataURL("image/png");
|
||||
const selection = selectionContext.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const mask = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
for (let index = 3; index < mask.data.length; index += 4) {
|
||||
if (selection.data[index] > 0) mask.data[index] = 0;
|
||||
}
|
||||
context.putImageData(mask, 0, 0);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
@@ -11,8 +11,11 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasPromptLibrary } from "./canvas-prompt-library";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types";
|
||||
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||
|
||||
export type CanvasNodeGenerationMode = CanvasGenerationMode;
|
||||
|
||||
@@ -22,10 +25,11 @@ type CanvasNodePromptPanelProps = {
|
||||
onPromptChange: (nodeId: string, prompt: string) => void;
|
||||
onConfigChange: (nodeId: string, patch: Partial<CanvasNodeData["metadata"]>) => void;
|
||||
onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
onImageSettingsOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||
export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, mentionReferences = [], onImageSettingsOpenChange }: CanvasNodePromptPanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const modelCosts = useConfigStore((state) => state.publicSettings?.modelChannel.modelCosts);
|
||||
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
|
||||
@@ -62,17 +66,14 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<textarea
|
||||
<CanvasResourceMentionTextarea
|
||||
value={prompt}
|
||||
onChange={(event) => updatePrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" || event.ctrlKey || event.metaKey || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
references={mentionReferences}
|
||||
onChange={updatePrompt}
|
||||
onSubmit={submit}
|
||||
className="thin-scrollbar h-24 w-full resize-none rounded-xl border px-3 py-2 text-sm leading-5 outline-none"
|
||||
style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }}
|
||||
placeholder={mode === "video" ? "描述要生成的视频内容" : mode === "image" ? (hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容") : hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容"}
|
||||
placeholder={promptPlaceholder(mode, hasImageContent, hasTextContent)}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex min-w-0 items-center justify-between gap-2">
|
||||
@@ -80,7 +81,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
@@ -92,11 +93,16 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</>
|
||||
) : mode === "video" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
</>
|
||||
) : mode === "audio" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="audio" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasAudioSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -120,27 +126,45 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
}
|
||||
|
||||
function defaultMode(type: CanvasNodeData["type"]): CanvasNodeGenerationMode {
|
||||
return type === CanvasNodeType.Text ? "text" : type === CanvasNodeType.Video ? "video" : "image";
|
||||
return type === CanvasNodeType.Text ? "text" : type === CanvasNodeType.Video ? "video" : type === CanvasNodeType.Audio ? "audio" : "image";
|
||||
}
|
||||
|
||||
function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: CanvasNodeGenerationMode): AiConfig {
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : globalConfig.textModel;
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
return {
|
||||
...globalConfig,
|
||||
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
|
||||
model: node.metadata?.model || defaultModel || (mode === "audio" ? defaultConfig.audioModel : globalConfig.model || defaultConfig.model),
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
|
||||
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count),
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
|
||||
audioInstructions: node.metadata?.audioInstructions || globalConfig.audioInstructions || defaultConfig.audioInstructions,
|
||||
count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count),
|
||||
};
|
||||
}
|
||||
|
||||
function promptPlaceholder(mode: CanvasNodeGenerationMode, hasImageContent: boolean, hasTextContent: boolean) {
|
||||
if (mode === "video") return "描述要生成的视频内容";
|
||||
if (mode === "audio") return "描述要生成的音频内容";
|
||||
if (mode === "image") return hasImageContent ? "请输入你想要把这张图修改成什么" : "描述要生成的图片内容";
|
||||
return hasTextContent ? "请输入你想要将本段文本修改成什么" : "请输入你想要生成的文本内容";
|
||||
}
|
||||
|
||||
function videoConfigPatch(key: keyof AiConfig, value: string) {
|
||||
if (key === "videoSeconds") return { seconds: value };
|
||||
if (key === "videoGenerateAudio") return { generateAudio: value };
|
||||
if (key === "videoWatermark") return { watermark: value };
|
||||
return { [key]: value };
|
||||
}
|
||||
|
||||
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
|
||||
if (key === "audioVoice") return { audioVoice: value };
|
||||
if (key === "audioFormat") return { audioFormat: value };
|
||||
if (key === "audioSpeed") return { audioSpeed: value };
|
||||
return { audioInstructions: value };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, InputNumber, Modal } from "antd";
|
||||
import { Grid2x2 } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import type { ImageSplitParams } from "../utils/canvas-image-data";
|
||||
|
||||
export type CanvasImageSplitParams = ImageSplitParams;
|
||||
|
||||
const defaultParams: CanvasImageSplitParams = { rows: 2, columns: 2 };
|
||||
const maxGridSize = 12;
|
||||
|
||||
export function CanvasNodeSplitDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageSplitParams) => void }) {
|
||||
const [params, setParams] = useState(defaultParams);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const total = params.rows * params.columns;
|
||||
const pieceSize = image ? { width: Math.max(1, Math.floor(image.width / params.columns)), height: Math.max(1, Math.floor(image.height / params.rows)) } : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setParams(defaultParams);
|
||||
setImage(null);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
const update = (key: keyof CanvasImageSplitParams, value: string | number | null) => {
|
||||
setParams((current) => ({ ...current, [key]: clampGrid(value ?? current[key]) }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={780} centered destroyOnHidden>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">切分图片</h2>
|
||||
<p className="mt-1 text-sm opacity-60">生成 {total} 个图片子节点,并按原图网格排列到画布右侧</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_280px]">
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="grid min-h-[300px] place-items-center rounded-lg bg-black/5">
|
||||
<div className="relative inline-block max-w-full overflow-hidden rounded-lg bg-black shadow-xl">
|
||||
<img src={dataUrl} alt="" className="block max-h-[340px] max-w-full object-contain opacity-95" draggable={false} />
|
||||
<SplitGrid rows={params.rows} columns={params.columns} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="opacity-60">原图</span>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 py-2">
|
||||
<NumberField label="行数" value={params.rows} onChange={(value) => update("rows", value)} />
|
||||
<NumberField label="列数" value={params.columns} onChange={(value) => update("columns", value)} />
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="opacity-60">子节点</span>
|
||||
<span className="font-semibold">{total} 个</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="opacity-60">单块约</span>
|
||||
<span className="font-semibold">{pieceSize ? `${pieceSize.width} x ${pieceSize.height}` : "未知"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" size="large" className="w-full" icon={<Grid2x2 className="size-4" />} onClick={() => onConfirm(params)}>
|
||||
生成子节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberField({ label, value, onChange }: { label: string; value: number; onChange: (value: string | number | null) => void }) {
|
||||
return (
|
||||
<label className="block space-y-2">
|
||||
<span className="font-medium opacity-75">{label}</span>
|
||||
<InputNumber className="w-full" min={1} max={maxGridSize} precision={0} value={value} onChange={onChange} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SplitGrid({ rows, columns }: CanvasImageSplitParams) {
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0">
|
||||
{Array.from({ length: columns - 1 }).map((_, index) => (
|
||||
<div key={`column-${index}`} className="absolute inset-y-0 border-l border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ left: `${((index + 1) / columns) * 100}%` }} />
|
||||
))}
|
||||
{Array.from({ length: rows - 1 }).map((_, index) => (
|
||||
<div key={`row-${index}`} className="absolute inset-x-0 border-t border-white/90 shadow-[0_0_0_1px_rgba(0,0,0,.35)]" style={{ top: `${((index + 1) / rows) * 100}%` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function clampGrid(value: string | number) {
|
||||
const numberValue = Number(value);
|
||||
return Math.min(maxGridSize, Math.max(1, Math.round(Number.isFinite(numberValue) ? numberValue : 1)));
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Button, Modal, Segmented } from "antd";
|
||||
import { ImagePlus } from "lucide-react";
|
||||
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize, type ImageUpscaleAlgorithm, type ImageUpscaleParams } from "../utils/canvas-image-data";
|
||||
|
||||
export type CanvasImageUpscaleParams = ImageUpscaleParams;
|
||||
|
||||
const algorithms: Array<{ value: ImageUpscaleAlgorithm; title: string; description: string }> = [
|
||||
{ value: "high", title: "高清插值", description: "适合照片和细节图" },
|
||||
{ value: "bilinear", title: "双线性", description: "平滑、速度快" },
|
||||
{ value: "nearest", title: "最近邻", description: "适合像素风格" },
|
||||
];
|
||||
|
||||
const targetOptions = [
|
||||
{ label: "1K", value: 1024 },
|
||||
{ label: "2K", value: 2048 },
|
||||
{ label: "4K", value: MAX_UPSCALE_LONG_EDGE },
|
||||
];
|
||||
|
||||
const defaultParams: CanvasImageUpscaleParams = {
|
||||
targetLongEdge: 2048,
|
||||
algorithm: "high",
|
||||
};
|
||||
|
||||
export function CanvasNodeUpscaleDialog({ dataUrl, open, onClose, onConfirm }: { dataUrl: string; open: boolean; onClose: () => void; onConfirm: (params: CanvasImageUpscaleParams) => void }) {
|
||||
const [params, setParams] = useState<CanvasImageUpscaleParams>(defaultParams);
|
||||
const [image, setImage] = useState<{ width: number; height: number } | null>(null);
|
||||
const sourceLongEdge = image ? Math.max(image.width, image.height) : 0;
|
||||
const outputSize = useMemo(() => (image ? resolveUpscaleSize(image.width, image.height, params.targetLongEdge) : null), [image, params.targetLongEdge]);
|
||||
const canUpscale = Boolean(image && sourceLongEdge < params.targetLongEdge && params.targetLongEdge <= MAX_UPSCALE_LONG_EDGE);
|
||||
const reachedMax = Boolean(image && sourceLongEdge >= MAX_UPSCALE_LONG_EDGE);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setParams(defaultParams);
|
||||
setImage(null);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
void readImageMeta(dataUrl).then(setImage);
|
||||
}, [dataUrl, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!image) return;
|
||||
const nextTarget = targetOptions.find((option) => sourceLongEdge < option.value)?.value || MAX_UPSCALE_LONG_EDGE;
|
||||
setParams((current) => ({ ...current, targetLongEdge: nextTarget }));
|
||||
}, [image, sourceLongEdge]);
|
||||
|
||||
return (
|
||||
<Modal title={null} open={open && Boolean(dataUrl)} onCancel={onClose} footer={null} width={820} centered destroyOnHidden>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">图片放大</h2>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-[minmax(260px,1fr)_360px]">
|
||||
<div className="rounded-xl border p-4">
|
||||
<div className="grid min-h-[280px] place-items-center rounded-lg bg-black/5">
|
||||
<img src={dataUrl} alt="" className="max-h-[320px] max-w-full rounded-lg object-contain shadow-xl" draggable={false} />
|
||||
</div>
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="opacity-60">源图</span>
|
||||
<span className="font-semibold">{image ? `${image.width} x ${image.height} px` : "读取中"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-6 py-2">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium opacity-75">目标像素</div>
|
||||
<Segmented
|
||||
block
|
||||
value={params.targetLongEdge}
|
||||
options={targetOptions.map((option) => ({ label: `${option.label} · ${option.value}px`, value: option.value, disabled: Boolean(image && sourceLongEdge >= option.value) }))}
|
||||
onChange={(value) => setParams((current) => ({ ...current, targetLongEdge: Number(value) }))}
|
||||
/>
|
||||
{image && !canUpscale ? <div className="text-xs font-medium text-[#ef4444]">{reachedMax ? "图片已达到 4K,无需放大" : "图片已达到当前目标像素,无需放大"}</div> : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium opacity-75">放大算法</div>
|
||||
<Segmented
|
||||
block
|
||||
value={params.algorithm}
|
||||
options={algorithms.map((item) => ({
|
||||
value: item.value,
|
||||
label: (
|
||||
<span className="flex min-h-12 flex-col justify-center text-left leading-5">
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<span className="text-xs opacity-55">{item.description}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
onChange={(value) => setParams((current) => ({ ...current, algorithm: value as ImageUpscaleAlgorithm }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-xl border px-4 py-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="opacity-60">输出尺寸</span>
|
||||
<span className="font-semibold">{outputSize ? `${outputSize.width} x ${outputSize.height} px` : "未知"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="primary" size="large" icon={<ImagePlus className="size-4" />} disabled={!canUpscale} onClick={() => onConfirm(params)}>
|
||||
生成放大图
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,9 @@ import { ChevronRight, Image as ImageIcon, Music2, RefreshCw, Star, Video } from
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { formatBytes } from "@/lib/image-utils";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea";
|
||||
import { CanvasNodeType, type CanvasNodeData, type Position } from "../types";
|
||||
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||
|
||||
type ResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
const selectionBlue = "#2f80ff";
|
||||
@@ -23,6 +25,8 @@ type CanvasNodeProps = {
|
||||
editRequestNonce?: number;
|
||||
showPanel: boolean;
|
||||
showImageInfo: boolean;
|
||||
resourceLabel?: CanvasResourceReference;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
renderPanel?: (node: CanvasNodeData) => ReactNode;
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
batchCount?: number;
|
||||
@@ -41,6 +45,7 @@ type CanvasNodeProps = {
|
||||
onSetBatchPrimary?: (node: CanvasNodeData) => void;
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onViewImage?: (node: CanvasNodeData) => void;
|
||||
onContextMenu: (event: React.MouseEvent, nodeId: string) => void;
|
||||
};
|
||||
|
||||
@@ -57,6 +62,7 @@ type NodeContentRendererProps = {
|
||||
renderNodeContent?: (node: CanvasNodeData) => ReactNode;
|
||||
onContentChange: (nodeId: string, content: string) => void;
|
||||
onStopEditing: () => void;
|
||||
mentionReferences: CanvasResourceReference[];
|
||||
onRetry?: (node: CanvasNodeData) => void;
|
||||
onGenerateImage?: (node: CanvasNodeData) => void;
|
||||
onToggleBatch?: () => void;
|
||||
@@ -74,6 +80,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
editRequestNonce = 0,
|
||||
showPanel,
|
||||
showImageInfo,
|
||||
resourceLabel,
|
||||
mentionReferences = [],
|
||||
renderPanel,
|
||||
renderNodeContent,
|
||||
batchCount = 0,
|
||||
@@ -92,6 +100,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onSetBatchPrimary,
|
||||
onRetry,
|
||||
onGenerateImage,
|
||||
onViewImage,
|
||||
onContextMenu,
|
||||
}: CanvasNodeProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
@@ -262,6 +271,11 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
onToggleBatch?.(data.id);
|
||||
return;
|
||||
}
|
||||
if (data.type === CanvasNodeType.Image && hasImageContent) {
|
||||
event.stopPropagation();
|
||||
onViewImage?.(data);
|
||||
return;
|
||||
}
|
||||
if (data.type !== CanvasNodeType.Text) return;
|
||||
event.stopPropagation();
|
||||
setIsEditingContent(true);
|
||||
@@ -291,6 +305,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
batchOpening={batchOpening}
|
||||
batchRecovering={batchRecovering}
|
||||
renderNodeContent={renderNodeContent}
|
||||
mentionReferences={mentionReferences}
|
||||
onContentChange={onContentChange}
|
||||
onStopEditing={() => setIsEditingContent(false)}
|
||||
onRetry={onRetry}
|
||||
@@ -301,6 +316,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
|
||||
{showImageInfo && hasImageContent ? <ImageInfoBar node={data} /> : null}
|
||||
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} /> : null}
|
||||
|
||||
{!hasImageContent && !hasVideoContent && !hasAudioContent ? <div className="pointer-events-none absolute inset-x-0 bottom-0 h-12" style={{ background: `linear-gradient(to top, ${theme.canvas.background}66, transparent)` }} /> : null}
|
||||
|
||||
@@ -313,7 +329,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<ConnectionHandleDot side="left" visible={hovered || isSelected || isConnecting} onMouseDown={(event) => onConnectStart(event, data.id, "target")} />
|
||||
<ConnectionHandleDot side="right" visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} onMouseDown={(event) => onConnectStart(event, data.id, "source")} />
|
||||
|
||||
{showPanel && renderPanel && data.type !== CanvasNodeType.Config ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
{showPanel && renderPanel ? <div className="absolute left-1/2 top-full z-[70] w-[500px] -translate-x-1/2 pt-4">{renderPanel(data)}</div> : null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -325,7 +341,7 @@ function NodeContent(props: NodeContentRendererProps) {
|
||||
if (props.node.metadata?.status === "error") return <ErrorContent node={props.node} theme={props.theme} onRetry={props.onRetry} />;
|
||||
|
||||
const Renderer = nodeContentRenderers[props.node.type];
|
||||
return <Renderer {...props} />;
|
||||
return Renderer ? <Renderer {...props} /> : <UnknownNodeContent theme={props.theme} />;
|
||||
}
|
||||
|
||||
const nodeContentRenderers = {
|
||||
@@ -366,7 +382,18 @@ function ErrorContent({ node, theme, onRetry }: Pick<NodeContentRendererProps, "
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({ node, theme, isEditingContent, textareaRef, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||
function UnknownNodeContent({ theme }: Pick<NodeContentRendererProps, "theme">) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-sm" style={{ color: theme.node.placeholder }}>
|
||||
未知节点
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextContent({ node, theme, isEditingContent, textareaRef, mentionReferences, onContentChange, onStopEditing, onGenerateImage }: NodeContentRendererProps) {
|
||||
const fontSize = node.metadata?.fontSize || 14;
|
||||
const textStyle = { fontSize: `${fontSize}px`, lineHeight: `${Math.round(fontSize * 1.65)}px`, color: theme.node.text, boxSizing: "border-box" } as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden pt-8">
|
||||
<button
|
||||
@@ -386,12 +413,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
|
||||
生图
|
||||
</button>
|
||||
{isEditingContent ? (
|
||||
<textarea
|
||||
<CanvasResourceMentionTextarea
|
||||
ref={textareaRef}
|
||||
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono leading-relaxed outline-none select-text appearance-none"
|
||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
||||
className="thin-scrollbar block h-full w-full resize-none overflow-y-auto whitespace-pre-wrap break-words border-none bg-transparent pl-4 pr-14 pt-0 pb-4 m-0 font-mono outline-none select-text appearance-none"
|
||||
style={textStyle}
|
||||
value={node.metadata?.content || ""}
|
||||
onChange={(event) => onContentChange(node.id, event.target.value)}
|
||||
references={mentionReferences}
|
||||
highlightLabels={false}
|
||||
onChange={(value) => onContentChange(node.id, value)}
|
||||
onBlur={onStopEditing}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") onStopEditing();
|
||||
@@ -402,8 +431,8 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono leading-relaxed"
|
||||
style={{ fontSize: `${node.metadata?.fontSize || 14}px`, color: theme.node.text }}
|
||||
className="thin-scrollbar block h-full w-full overflow-y-auto whitespace-pre-wrap break-words bg-transparent pl-4 pr-14 pt-0 pb-4 font-mono"
|
||||
style={textStyle}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
{node.metadata?.content || <span style={{ color: theme.node.placeholder }}>双击编辑文字</span>}
|
||||
@@ -413,6 +442,14 @@ function TextContent({ node, theme, isEditingContent, textareaRef, onContentChan
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabelBadge({ reference }: { reference: CanvasResourceReference }) {
|
||||
return (
|
||||
<span className={`pointer-events-none absolute right-2 top-2 z-30 rounded-md px-1.5 py-0.5 text-[10px] font-medium ${reference.active ? "bg-[#2f80ff] text-white shadow-sm" : "bg-black/35 text-white/75"}`}>
|
||||
{reference.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageNodeContent(props: NodeContentRendererProps) {
|
||||
if (!props.node.metadata?.content && props.isBatchRoot) {
|
||||
const content =
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, MouseEvent, PointerEvent, TextareaHTMLAttributes } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FileText, Image as ImageIcon, Music2, Video } from "lucide-react";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import type { CanvasResourceReference } from "../utils/canvas-resource-references";
|
||||
|
||||
type MentionState = {
|
||||
start: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "onChange" | "value"> & {
|
||||
value: string;
|
||||
references: CanvasResourceReference[];
|
||||
onChange: (value: string) => void;
|
||||
onSubmit?: () => void;
|
||||
containerClassName?: string;
|
||||
highlightLabels?: boolean;
|
||||
};
|
||||
|
||||
export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Props>(function CanvasResourceMentionTextarea({ value, references, onChange, onSubmit, onKeyDown, className, containerClassName, style, highlightLabels = true, ...props }, forwardedRef) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const [mention, setMention] = useState<MentionState | null>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
const candidates = useMemo(() => {
|
||||
if (!mention) return [];
|
||||
const query = mention.query.trim().toLowerCase();
|
||||
const activeReferences = references.filter((item) => item.active);
|
||||
if (!query) return activeReferences;
|
||||
return activeReferences.filter((item) => `${item.label} ${item.title} ${item.kind} ${item.text || ""}`.toLowerCase().includes(query));
|
||||
}, [mention, references]);
|
||||
const activeLabels = useMemo(() => (highlightLabels ? Array.from(new Set(references.filter((item) => item.active).map((item) => item.label))).sort((a, b) => b.length - a.length) : []), [highlightLabels, references]);
|
||||
|
||||
const updateValue = (next: string, selectionStart?: number) => {
|
||||
onChange(next);
|
||||
if (typeof selectionStart !== "number") return;
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
textareaRef.current?.setSelectionRange(selectionStart, selectionStart);
|
||||
});
|
||||
};
|
||||
|
||||
const closeMention = () => {
|
||||
setMention(null);
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const syncMention = (nextValue: string, cursor: number) => {
|
||||
const prefix = nextValue.slice(0, cursor);
|
||||
const match = /(^|\s)@([^\s@]*)$/.exec(prefix);
|
||||
if (!match || !references.some((item) => item.active)) {
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
setMention({ start: cursor - match[2].length - 1, query: match[2] });
|
||||
setActiveIndex(0);
|
||||
};
|
||||
|
||||
const insertReference = (reference: CanvasResourceReference) => {
|
||||
if (!mention) return;
|
||||
const textarea = textareaRef.current;
|
||||
const end = textarea?.selectionStart ?? value.length;
|
||||
const insertText = `${reference.label} `;
|
||||
const next = `${value.slice(0, mention.start)}${insertText}${value.slice(end)}`;
|
||||
closeMention();
|
||||
updateValue(next, mention.start + insertText.length);
|
||||
};
|
||||
|
||||
const syncOverlayScroll = () => {
|
||||
if (!overlayRef.current || !textareaRef.current) return;
|
||||
overlayRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
overlayRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
};
|
||||
|
||||
const updateSelectionState = () => {
|
||||
const textarea = textareaRef.current;
|
||||
setHasSelection(Boolean(textarea && textarea.selectionStart !== textarea.selectionEnd));
|
||||
};
|
||||
|
||||
const showOverlay = Boolean(activeLabels.length && !hasSelection);
|
||||
const mergedStyle = {
|
||||
...(style || {}),
|
||||
color: showOverlay ? "transparent" : style?.color,
|
||||
caretColor: style?.color || theme.node.text,
|
||||
...(showOverlay ? { background: "transparent", backgroundColor: "transparent" } : {}),
|
||||
} as CSSProperties;
|
||||
const menu = mention && candidates.length && textareaRef.current ? <MentionMenu textarea={textareaRef.current} references={candidates} activeIndex={Math.min(activeIndex, candidates.length - 1)} theme={theme} onSelect={insertReference} /> : null;
|
||||
|
||||
return (
|
||||
<div className={`relative h-full w-full ${containerClassName || ""}`}>
|
||||
{showOverlay ? (
|
||||
<div ref={overlayRef} className={`${className || ""} pointer-events-none absolute inset-0 overflow-hidden whitespace-pre-wrap break-words`} style={{ ...style, color: theme.node.text }}>
|
||||
<MentionHighlightText value={value || props.placeholder?.toString() || ""} labels={activeLabels} placeholder={!value} />
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
{...props}
|
||||
ref={(node) => {
|
||||
textareaRef.current = node;
|
||||
if (typeof forwardedRef === "function") forwardedRef(node);
|
||||
else if (forwardedRef) forwardedRef.current = node;
|
||||
}}
|
||||
value={value}
|
||||
className={className}
|
||||
style={mergedStyle}
|
||||
onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
onChange(next);
|
||||
syncMention(next, event.target.selectionStart);
|
||||
requestAnimationFrame(() => {
|
||||
syncOverlayScroll();
|
||||
updateSelectionState();
|
||||
});
|
||||
}}
|
||||
onSelect={(event) => {
|
||||
updateSelectionState();
|
||||
props.onSelect?.(event);
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
updateSelectionState();
|
||||
props.onKeyUp?.(event);
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
updateSelectionState();
|
||||
props.onPointerUp?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (mention && candidates.length) {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index + 1) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActiveIndex((index) => (index - 1 + candidates.length) % candidates.length);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
insertReference(candidates[Math.min(activeIndex, candidates.length - 1)]);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeMention();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === "Enter" && onSubmit && !event.ctrlKey && !event.metaKey && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
return;
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
}}
|
||||
onScroll={(event) => {
|
||||
syncOverlayScroll();
|
||||
props.onScroll?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setHasSelection(false);
|
||||
window.setTimeout(closeMention, 120);
|
||||
props.onBlur?.(event);
|
||||
}}
|
||||
/>
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function MentionHighlightText({ value, labels, placeholder }: { value: string; labels: string[]; placeholder: boolean }) {
|
||||
if (placeholder) return <span className="opacity-45">{value}</span>;
|
||||
if (!labels.length) return <>{value}</>;
|
||||
const pattern = new RegExp(`(${labels.map(escapeRegExp).join("|")})`, "g");
|
||||
return (
|
||||
<>
|
||||
{value.split(pattern).map((part, index) =>
|
||||
labels.includes(part) ? (
|
||||
<span key={`${part}-${index}`} className="rounded-md bg-[#2f80ff]/16 px-1 py-0.5 font-medium text-[#2f80ff] ring-1 ring-[#2f80ff]/24">
|
||||
{part}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`${part}-${index}`}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MentionMenu({ textarea, references, activeIndex, theme, onSelect }: { textarea: HTMLTextAreaElement; references: CanvasResourceReference[]; activeIndex: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onSelect: (reference: CanvasResourceReference) => void }) {
|
||||
const selectedRef = useRef(false);
|
||||
const rect = textarea.getBoundingClientRect();
|
||||
const boundary = textarea.closest(".ant-modal-content")?.getBoundingClientRect() || { left: 8, top: 8, right: window.innerWidth - 8, bottom: window.innerHeight - 8 };
|
||||
const menuWidth = 256;
|
||||
const maxMenuHeight = 224;
|
||||
const gap = 6;
|
||||
const left = clamp(rect.left, boundary.left + 8, boundary.right - menuWidth - 8);
|
||||
const showAbove = rect.bottom + gap + maxMenuHeight > boundary.bottom && rect.top - gap - maxMenuHeight >= boundary.top;
|
||||
const top = clamp(showAbove ? rect.top - gap - maxMenuHeight : rect.bottom + gap, boundary.top + 8, boundary.bottom - maxMenuHeight - 8);
|
||||
|
||||
const stopCanvasInteraction = (event: PointerEvent | MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
const selectReference = (reference: CanvasResourceReference) => {
|
||||
if (selectedRef.current) return;
|
||||
selectedRef.current = true;
|
||||
onSelect(reference);
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
data-canvas-resource-mention-menu="true"
|
||||
className="fixed z-[120] max-h-56 w-64 overflow-y-auto rounded-xl border p-1 shadow-2xl backdrop-blur-md"
|
||||
style={{ left, top, background: theme.toolbar.panel, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{references.map((reference, index) => (
|
||||
<button
|
||||
key={reference.id}
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2 rounded-lg px-2 py-1.5 text-left text-xs transition"
|
||||
style={{ background: index === activeIndex ? theme.toolbar.activeBg : "transparent", color: index === activeIndex ? theme.toolbar.activeText : theme.node.text }}
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectReference(reference);
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectReference(reference);
|
||||
}}
|
||||
>
|
||||
<ReferencePreview reference={reference} />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block font-medium">{reference.label}</span>
|
||||
<span className="block truncate opacity-65">{reference.text || reference.title}</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
function ReferencePreview({ reference }: { reference: CanvasResourceReference }) {
|
||||
if (reference.kind === "image" && reference.previewUrl) return <img src={reference.previewUrl} alt="" className="size-9 rounded-md object-cover" />;
|
||||
if (reference.kind === "video" && reference.previewUrl) return <video src={reference.previewUrl} className="size-9 rounded-md bg-black object-cover" muted preload="metadata" />;
|
||||
const Icon = reference.kind === "audio" ? Music2 : reference.kind === "video" ? Video : reference.kind === "image" ? ImageIcon : FileText;
|
||||
return (
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-md bg-black/10">
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (max < min) return min;
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -28,6 +28,7 @@ type CanvasStore = {
|
||||
openProject: (id: string) => CanvasProject | null;
|
||||
renameProject: (id: string, title: string) => void;
|
||||
deleteProjects: (ids: string[]) => void;
|
||||
replaceProjects: (projects: CanvasProject[]) => void;
|
||||
updateProject: (id: string, patch: Partial<Pick<CanvasProject, "nodes" | "connections" | "chatSessions" | "activeChatId" | "backgroundMode" | "showImageInfo" | "viewport">>) => void;
|
||||
};
|
||||
|
||||
@@ -112,6 +113,7 @@ export const useCanvasStore = create<CanvasStore>()(
|
||||
const projects = state.projects.filter((project) => !ids.includes(project.id));
|
||||
return { projects };
|
||||
}),
|
||||
replaceProjects: (projects) => set({ projects }),
|
||||
updateProject: (id, patch) =>
|
||||
set((state) => ({
|
||||
projects: state.projects.map((project) => (project.id === id ? { ...project, ...patch, updatedAt: new Date().toISOString() } : project)),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user