diff --git a/.gitignore b/.gitignore index bad17a3..c4d9bc1 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ output/ # web web/.next/ web/out/ +web/public/canvas/ +.pnpm-store/ diff --git a/.project.json b/.project.json index e4930e3..2318578 100644 --- a/.project.json +++ b/.project.json @@ -84,6 +84,11 @@ "type" : "backend", "url" : "https:\/\/marketing.skg.com\/api" }, + { + "label" : "production-canvas", + "type" : "app", + "url" : "https:\/\/marketing.skg.com\/canvas\/" + }, { "label" : "agent-cut-preview", "type" : "app", diff --git a/Dockerfile.web b/Dockerfile.web index df8ca58..69c979b 100644 --- a/Dockerfile.web +++ b/Dockerfile.web @@ -5,7 +5,8 @@ WORKDIR /app RUN corepack enable && corepack prepare pnpm@10.28.2 --activate COPY web/package.json web/pnpm-lock.yaml ./ -RUN pnpm install --frozen-lockfile +COPY web/canvas-app/package.json web/canvas-app/pnpm-lock.yaml ./canvas-app/ +RUN pnpm install --frozen-lockfile && cd canvas-app && pnpm install --frozen-lockfile COPY web ./ diff --git a/RULES.md b/RULES.md index efa4d35..43a0064 100644 --- a/RULES.md +++ b/RULES.md @@ -4,6 +4,7 @@ - 后台启动(不弹 Terminal):`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291,日志写入 `.logs/`) - 后台停止:`./scripts/stop-dev-background.sh` - 前端 dev:`cd web && npm run dev`(Next.js 16,端口 4290) +- 无限画布 dev:`cd web && npm run dev:canvas`(Vue / Vite,端口 4292;生产构建会输出到 `/canvas/`) - 后端 dev:`cd api && uvicorn main:app --host 127.0.0.1 --port 4291`(FastAPI,端口 4291,重任务用) - 注意:后端不要带 `--reload` 跑长下载 / 抽帧 / 音频任务;reload 会等待后台任务结束,导致 4291 端口占用但新请求卡住。 @@ -11,7 +12,7 @@ - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`) - 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译) -- 当前产品方向(2026-05-25 单对话框版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生成入口,服务约 6 名公司成员同时使用。首页默认只保留一个中央对话框,不再显示侧栏、灵感区、任务列表或大结果面板;用户先选择四种生成方式之一:文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词并点击生成。首帧 / 首尾帧模式只露必要图片上传位,图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;结果生成后从对话框下方进入 `/detail/?job=` 沉淀参考图、生成图、视频候选和提示词。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。 +- 当前产品方向(2026-05-25 单对话框 + 无限画布版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生成入口,服务公司内部成员同时使用。首页默认只保留一个中央对话框,不再显示侧栏、灵感区、任务列表或大结果面板;用户先选择四种生成方式之一:文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词并点击生成。首帧 / 首尾帧模式只露必要图片上传位,图片模式显示尺寸选择,视频模式显示画幅和真实可用时长选择。后端 `/health` 向前端返回可选图片 / 视频模型、图片尺寸、视频画幅和视频时长,首页允许用户选择图片模型(自动、GPT Image 2、Gemini 图片兜底)和视频模型(Seedance、Kling、Veo 3 等别名;实际可用模型以环境变量映射为准)。当前 Doubao / Seedance 生产链路单条视频最长按 15 秒暴露,不在 UI 显示 30 秒;如后续要 30 秒,需要改成多段生成后合成。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;结果生成后从对话框下方进入 `/detail/?job=` 沉淀参考图、生成图、视频候选和提示词。新增 `/canvas/` 作为个人无限画布入口,基于 huobao-canvas 交互逻辑改造为 SKG 内部版,界面去除原可见品牌/API 设置,生成调用本项目后端 `/api`,每个浏览器的画布项目先保存在本地 localStorage,图片/视频资产仍按登录用户写入后端 job。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) @@ -60,6 +61,7 @@ - 最近部署验证(2026-05-20):`f1c710e` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层中间栏先清空为待重构占位,不再接收拖拽或触发 subject-agent / subject-assets;右侧主体元素输出逻辑保持不变。 - 最近部署验证(2026-05-20):`7e763cf` 已推送并部署到 `/opt/skg-marketing-studio`;本地 `web/npm run build` 通过,生产 Docker 重建后 `./scripts/verify-prod-docker.sh` 通过(web/API 容器 Up、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。转换层改为参考帧分析 + 对话生成提示词 + 弹窗确认后再生成主体套图;右侧主体元素输出逻辑保持不变。部署时发现服务器 `WEB_AUTH_*` 环境变量缺失导致 `/auth/check` 503,已从 `/root/skg-marketing-studio-login.txt` 和新 session secret 恢复服务器 `deploy/.env.production` 后重启验证通过;后续同步生产代码必须继续排除服务器真实 `deploy/.env.production`。 - 主站 / 前端:`https://marketing.skg.com` +- 无限画布:`https://marketing.skg.com/canvas/` - API / 后端:`https://marketing.skg.com/api` - 代码仓库 / Gitea:`https://git.kang-kang.com/kangwan/20260512-skg-tk` - 文档 / 解析:`docs/source-analysis.html`(项目内独立文档,不公开挂主应用路由) @@ -68,7 +70,7 @@ - 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`) - 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。 - 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`;Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。 -- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`,`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443 +- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/`、`/_next/`、`/assets/`、`/skg-logo-black.svg`、`/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/`;`/canvas/` 是受同一登录保护的 Vue / Vite 无限画布静态应用,Nginx fallback 到 `/canvas/index.html`;`/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`;Traefik 通过 `coolify` 外部网络接入 80/443 - Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx,不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/`、`/_next/`、`/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。 - 当前音频解析:`https://ai.skg.com/azure/v1` 的 `gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种。 - 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library`、`./data/prompt_library` 和 `./data/_trash` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..db5b2fc --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,12 @@ +# Third Party Notices + +## huobao-canvas + +Portions of the internal SKG canvas module are adapted from `chatfire-AI/huobao-canvas`. + +- Source: https://github.com/chatfire-AI/huobao-canvas +- License note: the upstream README declares MIT licensing and links to a `LICENSE` file, but the cloned snapshot used for this integration did not include that file. +- Local integration path: `web/canvas-app/` +- SKG changes: branding, visible product text, model options, auth behavior, and API calls were changed for SKG internal use. + +This notice is kept in the repository for engineering traceability and is not shown in the product UI. diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 5e12e11..772404f 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -106,6 +106,17 @@ server { try_files $uri =404; } + location = /canvas { + return 308 /canvas/; + } + + location /canvas/ { + auth_request /__auth; + error_page 401 = @login_redirect; + root /usr/share/nginx/html; + try_files $uri $uri/ /canvas/index.html; + } + location = /skg-logo-black.svg { root /usr/share/nginx/html; try_files $uri =404; diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 1073201..fa9fbbd 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -538,6 +538,11 @@ https://marketing.skg.com 公司域名已解析到 VPS 76.13.31.179。线上由既有 Coolify / Traefik 负责 HTTPS 入口,项目 web 容器用 Nginx 承载静态前端;/login//_next//assets//skg-logo-black.svg/oasis-source/ 为公开登录页资源,未登录访问工作台跳转 /login/。登录页优先走飞书免登录,回调为 /api/auth/feishu/callback;账号密码保留为备用入口。/api/ 通过 auth_request 校验 FastAPI 会话 Cookie 后再反代,后端按 Cookie 里的用户身份隔离 JobAgentRun 数据。 + + 生产无限画布 + https://marketing.skg.com/canvas/ + 受同一登录保护的 Vue / Vite 无限画布应用。构建时先执行 pnpm build:canvas,把 web/canvas-app/dist 同步到 web/public/canvas,再由 Next 静态导出和 Nginx 承载;Nginx 对 /canvas/auth_request,并 fallback 到 /canvas/index.html 支持前端路由。画布项目当前保存在浏览器 localStorage,生成出来的图片 / 视频资产通过本项目 /api 写入当前登录用户自己的后端 job。 + 生产部署 ./scripts/deploy-prod-safe.sh @@ -597,7 +602,11 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、#383838 胶囊侧栏、rgba(255,255,255,.1) 玻璃面、backdrop-filter: blur(5px)20px 圆角、10px 10px 10px rgba(0,0,0,.3) 阴影和绿黄状态色;新增 skg-board-shellskg-board-railskg-glass-cardskg-glass-card--flatskg-status-orb 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px,展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token;暗色压低灰雾和面板底色,明亮模式改为暖白工作台,避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。 - web/app/page.tsx当前默认首页:单对话框生成台。页面只保留顶部极轻量品牌和中央对话框,四个主按钮是文生视频、文生图、首帧生视频、首尾帧生视频;首帧 / 首尾帧模式才显示上传位,用户必须手写提示词后点击生成。页面启动时读取 getRuntimeHealth,按 image_options / video_options 显示模型下拉,按 image_size_options 显示文生图尺寸,按 video_size_optionsvideo_duration_options 显示视频画幅和真实可用时长;当前 Doubao / Seedance 生产链路最多暴露 15 秒,不再把 30 秒作为单条可选项。每次生成都会创建新的轻量 Job,文生图调用 generateImage 并传图片模型和尺寸,视频调用 generateStoryboardVideo 并传视频模型、画幅和时长;首尾帧模式先用 createCreativeImageJob 保存首帧,再用 uploadReferenceFrame 保存尾帧并以 last_image 提交。首页视频提交后每 2.6 秒轮询 getJob,结果卡会把 queued 显示为“排队中 / 前方 N 个任务 / 你的上一个视频生成中”,把 in_progress 显示为生成进度,完成后直接显示可播放 controls,避免完成视频只是静态首帧看起来“没有效果”。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 + web/app/page.tsx当前默认首页:单对话框生成台。页面只保留顶部极轻量品牌和中央对话框,四个主按钮是文生视频、文生图、首帧生视频、首尾帧生视频;首帧 / 首尾帧模式才显示上传位,用户必须手写提示词后点击生成。页面启动时读取 getRuntimeHealth,按 image_options / video_options 显示模型下拉,按 image_size_options 显示文生图尺寸,按 video_size_optionsvideo_duration_options 显示视频画幅和真实可用时长;当前 Doubao / Seedance 生产链路最多暴露 15 秒,不再把 30 秒作为单条可选项。每次生成都会创建新的轻量 Job,文生图调用 generateImage 并传图片模型和尺寸,视频调用 generateStoryboardVideo 并传视频模型、画幅和时长;首尾帧模式先用 createCreativeImageJob 保存首帧,再用 uploadReferenceFrame 保存尾帧并以 last_image 提交。首页视频提交后每 2.6 秒轮询 getJob,结果卡会把 queued 显示为“排队中 / 前方 N 个任务 / 你的上一个视频生成中”,把 in_progress 显示为生成进度,完成后直接显示可播放 controls,避免完成视频只是静态首帧看起来“没有效果”。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;顶部新增“无限画布”入口指向 /canvas/。旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 + web/canvas-app/SKG 内部无限画布应用:从 chatfire-AI/huobao-canvas 交互逻辑改造而来,保留 Vue Flow 节点画布、项目列表、节点连接和批量下载等核心画布能力;移除可见原品牌、GitHub 链接、本地 API Key 设置和第三方 base URL 配置,首页和画布都改为 SKG 品牌。生产路径固定为 /canvas/,内部路由用 /canvas/p/:id?;来源说明保存在 THIRD_PARTY_NOTICES.md,不展示给终端用户。 + web/canvas-app/src/views/Canvas.vue无限画布主交互:底部悬浮 prompt composer 吸附在画布下方,提供文生视频、文生图、首帧生视频、首尾帧生视频四种模式;首帧 / 尾帧模式只显示必要上传位。提交后自动创建文本节点、参考图节点、图片配置节点或视频配置节点,并用 autoExecute 触发生成;首尾帧连线会用 imageRole 标记首帧和尾帧,方便视频节点按角色组织请求。 + web/canvas-app/src/hooks/useApi.js无限画布到本项目后端的适配层:不再读取浏览器 API Key,而是使用当前登录会话 Cookie 调用 /api。文生图 / 图生图先创建轻量 creative job,再调用 /frames/0/generate;文生视频 / 首帧 / 首尾帧视频调用 /storyboard/video 并轮询 /jobs/{id},完成后把图片或 mp4 URL 写回画布节点。 + web/scripts/sync-canvas-dist.mjs构建桥接脚本:把 Vite 产物 web/canvas-app/dist 清空复制到 web/public/canvas,使 Next 静态导出时把无限画布作为同域子路径一起打包。web/public/canvas/ 是生成产物,已加入 .gitignoreweb/app/detail/page.tsx任务详情页:静态导出路由 /detail/?job=<id>,通过 query 读取 job id,调用 getJob 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 generateImagegenerateStoryboardVideogenerateCreativeCopy,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。 web/app/agent/page.tsx新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 Agent Terminal 和最终成片播放器;通过 POST /agent-runs 创建受限后台状态机任务,通过 GET /agent-runs/{id} 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力,只承接“用户只看成品”的快速出片主路径。 web/components/ad-recreation-board.tsx信息流广告复刻工作表:外壳按 Figma “Dashboard Glassmorphism”参考整体改为黑灰玻璃工作台,WorkbenchRail 默认收起为拉满工作台可用高度的 65px 胶囊工具条,只保留真实动作入口:素材任务、资源库和主题切换;鼠标移入或键盘聚焦侧栏时,skg-board-rail 切换 is-open 并从左侧展开 320px 素材输入抽屉,点击素材任务按钮可固定展开。顶部从登录页式 brand strip 改为轻量生产控制条,左侧显示 未来健康 · 营销内容工作台、主标题 营销内容工作台 和副标题 信息流广告复刻生产,右侧保留素材/当前/视频/文案段/背景音指标,并用紫、黄绿、琥珀、青绿、绿色光斑卡片增强原版玻璃拟态的颜色层次。主内容只保留源视频拆解工作区,素材输入的数据流、接口、模型调用和状态推导不变。工作台外层已取消 1800x1000 固定基准画布、ResizeObserver 档位计算和 CSS zoom 整页缩放,改为正常流式桌面容器:min-height: 100vhwidth: 100%max-width: 1920px,并保留 min-width: 1280px 作为最低操作宽度;核心列宽不再被整体缩放,文字、图标和边线由浏览器原生字号渲染,避免小数缩放导致发虚。buildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。侧边素材输入面板只负责链接/上传和任务切换,不再重复放横版原视频预览;主画布源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;源视频工作区撤销右上“布局调节”临时面板,不再读取或写入 localStorage["skg-source-workspace-layout:v1"];当前固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 360px、参考帧池 140px、主体空态 78px;转换层不再固定拉长,按内容自然高度显示,内容过多时最多到 560px 后在自身区域内滚动;上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方主体链路改为上方参考帧池 + 转换层、下方主体元素结果栏。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,并通过 skg-audio-waveform 读取明暗主题变量,避免明亮模式继续使用黑底/白色波形;顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转视频时间点,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层改为轻量对话式生图确认区并拿到主操作宽度:左侧参考帧可点 + 或直接拖入转换层,本地图片拖入会通过 uploadReferenceFrame 保存为参考帧;转换层上方是参考输入区,下方不再显示当前要求摘要、保留元素副本或对话记录计数,只保留带张数控件的“发送消息”输入 composer;模型确认类回复不再逐条展示,生成英文 prompt 后发送区主按钮直接切换为“确认生成 N 张”,点击后才调用主体套图生成。主体元素结果栏在转换层下方,空态只占紧凑提示;有结果时按每次生成的套图文件夹显示,左侧横向展示当前套图,右侧切换套图包,保留单张重生和删除;缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端对卡通重构传 subject_style=cartoon_subject,其他方向传 subject_style=source_actor;形象锁定或自主描述空文本可走 reconstruction_mode=same,其他参考创新走 similar 并把参考帧作为 /images/edits 的 image refs 一起提交。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;生图入口会显示 gpt-image-2 / gemini-3-pro-image-preview 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 @@ -1037,6 +1046,7 @@ ProductRefStateItem { 运行配置 / 模型标注GET /healthgetRuntimeHealthModelTrace返回 models:ASR、asr_language(默认 auto,表示中文/英文/多语言自动识别)、asr_base_urlasr_remote_enabledasr_local_fallback_enabledasr_audio_fallback_enabledfaster_whisper、本机 ASR、ASR fallback、翻译、GPT 改写、GPT 画面理解、产品视角识别 product_view、主图像模型 gpt-image-2、图片故障兜底 image_fallbacks、图片尺寸 image_size_options、短时熔断状态 image_circuit、主体 6 视图模型链路、Azure OpenAI TTS、视频别名、视频画幅 video_size_options、真实可用视频时长 video_duration_options、单条最大秒数 video_max_duration_seconds 和 Seedance 服务商。当前 REWRITE_MODELAUDIO_REWRITE_MODELVISION_MODEL 默认使用 gpt-4o;如果旧环境变量仍写 gemini-*,后端会归一化回 GPT_TEXT_MODEL / REWRITE_MODEL。语音只走 Azure OpenAI TTS,models.voice_tts_paths 会回传当前尝试的语音路径,方便区分路径错误和语音服务不可用。前端所有当前主路径里会调用模型的按钮旁显示模型名,点击弹出小窗口查看模型链路和输入输出逻辑;不返回 API Key 或敏感凭证。 历史列表GET /jobslistJobs当前登录用户可见 job 精简列表(id/url/status/thumbnail/mtime/owner…),按 state.json mtime 倒序。前端 URL 无 ?job= 时拉它回填本人历史;带 limit 可截断。开启数据隔离时,飞书用户只看到自己的任务,历史无 owner 的旧任务只对备用账号可见。 创建任务POST /jobscreateJob提交 TK 链接,后台开始下载;后端会把当前登录用户写入 Job.owner_*,后续详情、素材文件、删除和生成接口都通过统一中间件校验归属。下载阶段默认不带 cookies;生产环境必须显式保持 YTDLP_COOKIES_FILE=YTDLP_COOKIES_FROM_BROWSER= 为空,避免容器内误读被打进镜像的开发 api/.env。 + 无限画布生成POST /creative/jobs/image
POST /jobs/{id}/frames/upload
POST /jobs/{id}/frames/{idx}/generate
POST /jobs/{id}/frames/{idx}/storyboard/video
GET /jobs/{id}web/canvas-app/src/hooks/useApi.js/canvas/ 不单独保存后端画布表,画布项目当前在浏览器 localStorage;一旦生成图片或视频,就通过同一套 creative job / frame / storyboard video 接口写入当前登录用户自己的 job 目录。文生图会创建空白 creative job 后生成图片;首帧 / 首尾帧视频会把上传图转成 frame,提交视频后用 skg:{jobId}:{videoId} 作为画布侧任务 id 轮询 /jobs/{id},直到视频状态完成或失败。 一键出片终端POST /agent-runs
GET /agent-runs
GET /agent-runs/{id}
GET /agent-runs/{id}/final.mp4
GET /agent-runs/{id}/contact.jpgweb/app/agent/page.tsx快速出片页的唯一主接口。前端提交 TikTok 链接和最多 6 张产品图;后端创建同 owner 的 JobAgentRun,后台执行下载、产品图归一化、透明骨架主体参考复制、12 段镜头计划、视频生成、失败镜头自动重跑一次、审片接触表和 ffmpeg 最终合成。列表、详情、最终 mp4 和接触表同样按 owner 隔离。 重试下载POST /jobs/{id}/download/retryretryJobDownload用于 TK 链接下载失败且没有 video_url 的素材;清空错误、重新进入下载状态,并在后台再次执行 pipeline_download。上传视频不能重下载,需要重新上传文件。 上传视频POST /jobs/uploaduploadJob保存 source.mp4,然后同样进入下载完成状态;当前上传后也加入第一步队列,下载完成后自动解析音频。 @@ -1101,6 +1111,12 @@ ProductRefStateItem { 不要作为当前用户主路径展示;后续迁移时按看板工作流重新整理。 web/components/nodes/index.tsxweb/components/lightbox.tsxweb/components/storyboard-workbench.tsx + + 无限画布 + 承载个人自由排列的创作空间:用户在画布上用对话框生成文本、图片和视频节点,结果按节点位置沉淀,不和默认首页的单条结果卡互相挤压。画布项目先保存在浏览器本地,生成资产进入后端个人 job。 + 当前不做团队共享画布、管理员总览、多人协同编辑或跨浏览器同步;也不让员工在浏览器里配置上游 API Key。 + web/canvas-app/deploy/nginx.confweb/scripts/sync-canvas-dist.mjs + 音频条 复刻工作表顶部触发音频解析;全文文案依据和音频解析结果摘要不再默认渲染;主展示以源视频工作区为准:竖版原视频在左,逐句时间轴在原视频下方,音频波形和参考帧池在右;底部 AudioStrip 当前不渲染。 @@ -1135,6 +1151,7 @@ ProductRefStateItem {
  • GPT Image 生图;当前 IMAGE_MODEL 和主体 6 视图链路默认使用 gpt-image-2,单次图片网关请求默认 60 秒超时;主模型超时、429、5xx 或网络错误时允许 gemini-3-pro-image-preview 兜底,并有 2 次失败 / 600 秒短时熔断。
  • 三字段分镜候选生成:默认行左侧露文案、场景一句话、人物+产品+动作,右侧直接展示横向视频轨;中文镜像失焦后会自动优化英文主值;支持 AI 改写预览、单条选择数量生成、追加生成、选中候选和整片按行排队提交。
  • 全局资源中心:提示词库和素材库可从顶部“资源库”打开;提示词可复制并计数,素材应用到 job 时会复制成本 job 内普通 asset。
  • +
  • 无限画布:/canvas/ 已作为登录后子应用接入,支持文生图、文生视频、首帧生视频、首尾帧生视频四种节点化生成;生成资产继续写入当前登录用户自己的后端 job。
  • @@ -1176,6 +1193,10 @@ ProductRefStateItem {

    改工作区语义

    “这个工作区的业务职责要改,不只是 UI 文案;请同步更新标题、说明、可点击行为、状态推导和本源码解析页。”

    +
    +

    改无限画布

    +

    “我在 /canvas/ 画布里,左侧或底部对话框、节点类型、生成结果排列、排队状态、下载或删除行为要怎么改。”

    +
    @@ -1183,6 +1204,19 @@ ProductRefStateItem {

    变更记录

    这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

    +
    +
    +

    2026-05-25 · 接入 SKG 内部无限画布

    + UI + API + Docs +
    +
    +

    问题:默认首页适合“一次生成一个结果”,但内部多人使用时,用户还需要把多次生成的图片、视频、提示词和参考图放在一个自由空间里整理,避免结果都挤在同一条对话或详情页里。

    +

    改动:新增 web/canvas-app/,将 huobao-canvas 的 Vue Flow 画布交互改造为 SKG 内部版:可见品牌、GitHub 入口、API Key 设置和外部服务商配置都已移除,保留项目列表、无限画布、节点连接、四模式 prompt composer 和生成结果节点。构建链路新增 pnpm build:canvasweb/scripts/sync-canvas-dist.mjs,生产 Nginx 新增受登录保护的 /canvas/ fallback 路由;首页顶部增加“无限画布”入口。

    +

    影响:画布项目目前保存在浏览器 localStorage,不是团队共享,也不做跨设备同步;生成图片和视频仍调用本项目 /api,按当前登录用户写入个人 job。第三方来源说明只保存在 THIRD_PARTY_NOTICES.md,不进入终端用户 UI。

    +
    +

    2026-05-25 · 视频生成进入个人公平队列

    diff --git a/web/app/page.tsx b/web/app/page.tsx index f457929..7a25961 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -511,15 +511,24 @@ export default function Home() { {activeMode.needsFirstFrame ? "图片作为参考帧" : "只根据文字生成"}
    - +
    + + + 无限画布 + + +
    diff --git a/web/canvas-app/.dockerignore b/web/canvas-app/.dockerignore new file mode 100644 index 0000000..8e8dcff --- /dev/null +++ b/web/canvas-app/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.git +.DS_Store +*.log +.env* diff --git a/web/canvas-app/.gitignore b/web/canvas-app/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/canvas-app/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/canvas-app/README.md b/web/canvas-app/README.md new file mode 100644 index 0000000..a28650d --- /dev/null +++ b/web/canvas-app/README.md @@ -0,0 +1,20 @@ +# SKG 无限画布 + +这是 SKG 营销内容工作台的内部画布模块,部署在主站 `/canvas/` 路径下。 + +## 内部使用方式 + +- `/canvas/`:项目入口和本机项目列表。 +- `/canvas/p/new`:直接进入一个新画布。 +- 画布里的生图、生视频请求统一走主后端 `/api`,员工不需要填写模型密钥。 +- 生成的图片和视频仍由主后端保存到当前登录用户可访问的任务数据里,画布状态保存在当前浏览器本地。 + +## 开发 + +```bash +cd web/canvas-app +pnpm install +pnpm dev +``` + +主站构建会自动执行 `web/package.json` 里的 `build:canvas`,把 Vite 输出同步到 `web/public/canvas/` 后再执行 Next 静态导出。 diff --git a/web/canvas-app/index.html b/web/canvas-app/index.html new file mode 100644 index 0000000..12312dc --- /dev/null +++ b/web/canvas-app/index.html @@ -0,0 +1,13 @@ + + + + + + + SKG 无限画布 + + +
    + + + diff --git a/web/canvas-app/package.json b/web/canvas-app/package.json new file mode 100644 index 0000000..68770fc --- /dev/null +++ b/web/canvas-app/package.json @@ -0,0 +1,30 @@ +{ + "name": "skg-internal-canvas", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vicons/ionicons5": "^0.13.0", + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.1", + "@vue-flow/minimap": "^1.5.4", + "axios": "^1.13.2", + "naive-ui": "^2.43.2", + "pinia": "^3.0.4", + "vue": "^3.5.24", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "autoprefixer": "^10.4.23", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.0", + "vite": "^5.2.0" + } +} diff --git a/web/canvas-app/pnpm-lock.yaml b/web/canvas-app/pnpm-lock.yaml new file mode 100644 index 0000000..829d9d7 --- /dev/null +++ b/web/canvas-app/pnpm-lock.yaml @@ -0,0 +1,2039 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@vicons/ionicons5': + specifier: ^0.13.0 + version: 0.13.0 + '@vue-flow/background': + specifier: ^1.3.2 + version: 1.3.2(@vue-flow/core@1.48.1(vue@3.5.26))(vue@3.5.26) + '@vue-flow/controls': + specifier: ^1.1.3 + version: 1.1.3(@vue-flow/core@1.48.1(vue@3.5.26))(vue@3.5.26) + '@vue-flow/core': + specifier: ^1.48.1 + version: 1.48.1(vue@3.5.26) + '@vue-flow/minimap': + specifier: ^1.5.4 + version: 1.5.4(@vue-flow/core@1.48.1(vue@3.5.26))(vue@3.5.26) + axios: + specifier: ^1.13.2 + version: 1.13.2 + naive-ui: + specifier: ^2.43.2 + version: 2.43.2(vue@3.5.26) + pinia: + specifier: ^3.0.4 + version: 3.0.4(vue@3.5.26) + vue: + specifier: ^3.5.24 + version: 3.5.26 + vue-router: + specifier: ^4.2.5 + version: 4.6.4(vue@3.5.26) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.0.4 + version: 5.2.4(vite@5.4.21)(vue@3.5.26) + autoprefixer: + specifier: ^10.4.23 + version: 10.4.23(postcss@8.5.6) + postcss: + specifier: ^8.5.6 + version: 8.5.6 + tailwindcss: + specifier: ^3.4.0 + version: 3.4.19 + vite: + specifier: ^5.2.0 + version: 5.4.21 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.6': + resolution: {integrity: sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.28.6': + resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} + engines: {node: '>=6.9.0'} + + '@css-render/plugin-bem@0.15.14': + resolution: {integrity: sha512-QK513CJ7yEQxm/P3EwsI+d+ha8kSOcjGvD6SevM41neEMxdULE+18iuQK6tEChAWMOQNQPLG/Rw3Khb69r5neg==} + peerDependencies: + css-render: ~0.15.14 + + '@css-render/vue3-ssr@0.15.14': + resolution: {integrity: sha512-//8027GSbxE9n3QlD73xFY6z4ZbHbvrOVB7AO6hsmrEzGbg+h2A09HboUyDgu+xsmj7JnvJD39Irt+2D0+iV8g==} + peerDependencies: + vue: ^3.0.11 + + '@emotion/hash@0.8.0': + resolution: {integrity: sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@juggle/resize-observer@3.4.0': + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@rollup/rollup-android-arm-eabi@4.55.1': + resolution: {integrity: sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.55.1': + resolution: {integrity: sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.55.1': + resolution: {integrity: sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.55.1': + resolution: {integrity: sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.55.1': + resolution: {integrity: sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.55.1': + resolution: {integrity: sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.55.1': + resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.55.1': + resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.55.1': + resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.55.1': + resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.55.1': + resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.55.1': + resolution: {integrity: sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + resolution: {integrity: sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + resolution: {integrity: sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.55.1': + resolution: {integrity: sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.55.1': + resolution: {integrity: sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/katex@0.16.8': + resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@vicons/ionicons5@0.13.0': + resolution: {integrity: sha512-zvZKBPjEXKN7AXNo2Na2uy+nvuv6SP4KAMQxpKL2vfHMj0fSvuw7JZcOPCjQC3e7ayssKnaoFVAhbYcW6v41qQ==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue-flow/background@1.3.2': + resolution: {integrity: sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/controls@1.1.3': + resolution: {integrity: sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue-flow/core@1.48.1': + resolution: {integrity: sha512-3IxaMBLvWRbznZ4CuK0kVhp4Y4lCDQx9nhi48Swp6PwPw29KNhmiKd2kaBogYeWjGLb/tLjlE9V0s3jEmKCYWw==} + peerDependencies: + vue: ^3.3.0 + + '@vue-flow/minimap@1.5.4': + resolution: {integrity: sha512-l4C+XTAXnRxsRpUdN7cAVFBennC1sVRzq4bDSpVK+ag7tdMczAnhFYGgbLkUw3v3sY6gokyWwMl8CDonp8eB2g==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + + '@vue/compiler-core@3.5.26': + resolution: {integrity: sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==} + + '@vue/compiler-dom@3.5.26': + resolution: {integrity: sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==} + + '@vue/compiler-sfc@3.5.26': + resolution: {integrity: sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==} + + '@vue/compiler-ssr@3.5.26': + resolution: {integrity: sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.9': + resolution: {integrity: sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==} + + '@vue/devtools-kit@7.7.9': + resolution: {integrity: sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==} + + '@vue/devtools-shared@7.7.9': + resolution: {integrity: sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==} + + '@vue/reactivity@3.5.26': + resolution: {integrity: sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==} + + '@vue/runtime-core@3.5.26': + resolution: {integrity: sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==} + + '@vue/runtime-dom@3.5.26': + resolution: {integrity: sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==} + + '@vue/server-renderer@3.5.26': + resolution: {integrity: sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==} + peerDependencies: + vue: 3.5.26 + + '@vue/shared@3.5.26': + resolution: {integrity: sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==} + + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + autoprefixer@10.4.23: + resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + baseline-browser-mapping@2.9.14: + resolution: {integrity: sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + caniuse-lite@1.0.30001764: + resolution: {integrity: sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + css-render@0.15.14: + resolution: {integrity: sha512-9nF4PdUle+5ta4W5SyZdLCCmFd37uVimSjg1evcTqKJCyvCEEj12WKzOSBNak6r4im4J4iYXKH1OWpUV5LBYFg==} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.0.11: + resolution: {integrity: sha512-sa6P2wJ+CAbgyy4KFssIb/JNMLxFvKF1pCYCSXS8ZMuqZnMsrxqI2E5sPyoTpxoPU/gVZMzr2zjOfg8GIZOMsw==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + entities@7.0.0: + resolution: {integrity: sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + evtd@0.2.4: + resolution: {integrity: sha512-qaeGN5bx63s/AXgQo8gj6fBkxge+OoLddLniox5qtLAEY5HSnuSlISXVPxnSae1dWblvTh4/HoMIB+mbMsvZzw==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fraction.js@5.3.4: + resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + jiti@1.21.7: + resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} + hasBin: true + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash-es@4.17.22: + resolution: {integrity: sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + naive-ui@2.43.2: + resolution: {integrity: sha512-YlLMnGrwGTOc+zMj90sG3ubaH5/7czsgLgGcjTLA981IUaz8r6t4WIujNt8r9PNr+dqv6XNEr0vxkARgPPjfBQ==} + peerDependencies: + vue: ^3.0.0 + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + postcss-import@15.1.0: + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + + postcss-js@4.1.0: + resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.2: + resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.55.1: + resolution: {integrity: sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + seemly@0.3.10: + resolution: {integrity: sha512-2+SMxtG1PcsL0uyhkumlOU6Qo9TAQ/WyH7tthnPIOQB05/12jz9naq6GZ6iZ6ApVsO3rr2gsnTf3++OV63kE1Q==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwindcss@3.4.19: + resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} + engines: {node: '>=14.0.0'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + treemate@0.3.11: + resolution: {integrity: sha512-M8RGFoKtZ8dF+iwJfAJTOH/SM4KluKOKRJpjCMhI8bG3qB74zrFoArKZ62ll0Fr3mqkMJiQOmWYkdYgDeITYQg==} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vdirs@0.1.8: + resolution: {integrity: sha512-H9V1zGRLQZg9b+GdMk8MXDN2Lva0zx72MPahDKc30v+DtwKjfyOSXWRIX4t2mhDubM1H09gPhWeth/BJWPHGUw==} + peerDependencies: + vue: ^3.0.11 + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vooks@0.2.12: + resolution: {integrity: sha512-iox0I3RZzxtKlcgYaStQYKEzWWGAduMmq+jS7OrNdQo1FgGfPMubGL3uGHOU9n97NIvfFDBGnpSvkWyb/NSn/Q==} + peerDependencies: + vue: ^3.0.0 + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue@3.5.26: + resolution: {integrity: sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + vueuc@0.4.65: + resolution: {integrity: sha512-lXuMl+8gsBmruudfxnMF9HW4be8rFziylXFu1VHVNbLVhRTXXV4njvpRuJapD/8q+oFEMSfQMH16E/85VoWRyQ==} + peerDependencies: + vue: ^3.0.11 + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.28.6': + dependencies: + '@babel/types': 7.28.6 + + '@babel/types@7.28.6': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@css-render/plugin-bem@0.15.14(css-render@0.15.14)': + dependencies: + css-render: 0.15.14 + + '@css-render/vue3-ssr@0.15.14(vue@3.5.26)': + dependencies: + vue: 3.5.26 + + '@emotion/hash@0.8.0': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@juggle/resize-observer@3.4.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@rollup/rollup-android-arm-eabi@4.55.1': + optional: true + + '@rollup/rollup-android-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.55.1': + optional: true + + '@rollup/rollup-darwin-x64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.55.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.55.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.55.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.55.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.55.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.55.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.55.1': + optional: true + + '@types/estree@1.0.8': {} + + '@types/katex@0.16.8': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.23 + + '@types/lodash@4.17.23': {} + + '@types/web-bluetooth@0.0.20': {} + + '@vicons/ionicons5@0.13.0': {} + + '@vitejs/plugin-vue@5.2.4(vite@5.4.21)(vue@3.5.26)': + dependencies: + vite: 5.4.21 + vue: 3.5.26 + + '@vue-flow/background@1.3.2(@vue-flow/core@1.48.1(vue@3.5.26))(vue@3.5.26)': + dependencies: + '@vue-flow/core': 1.48.1(vue@3.5.26) + vue: 3.5.26 + + '@vue-flow/controls@1.1.3(@vue-flow/core@1.48.1(vue@3.5.26))(vue@3.5.26)': + dependencies: + '@vue-flow/core': 1.48.1(vue@3.5.26) + vue: 3.5.26 + + '@vue-flow/core@1.48.1(vue@3.5.26)': + dependencies: + '@vueuse/core': 10.11.1(vue@3.5.26) + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.26 + transitivePeerDependencies: + - '@vue/composition-api' + + '@vue-flow/minimap@1.5.4(@vue-flow/core@1.48.1(vue@3.5.26))(vue@3.5.26)': + dependencies: + '@vue-flow/core': 1.48.1(vue@3.5.26) + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.5.26 + + '@vue/compiler-core@3.5.26': + dependencies: + '@babel/parser': 7.28.6 + '@vue/shared': 3.5.26 + entities: 7.0.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.26': + dependencies: + '@vue/compiler-core': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/compiler-sfc@3.5.26': + dependencies: + '@babel/parser': 7.28.6 + '@vue/compiler-core': 3.5.26 + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.6 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.26': + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.9': + dependencies: + '@vue/devtools-kit': 7.7.9 + + '@vue/devtools-kit@7.7.9': + dependencies: + '@vue/devtools-shared': 7.7.9 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.9': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.26': + dependencies: + '@vue/shared': 3.5.26 + + '@vue/runtime-core@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/shared': 3.5.26 + + '@vue/runtime-dom@3.5.26': + dependencies: + '@vue/reactivity': 3.5.26 + '@vue/runtime-core': 3.5.26 + '@vue/shared': 3.5.26 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.26(vue@3.5.26)': + dependencies: + '@vue/compiler-ssr': 3.5.26 + '@vue/shared': 3.5.26 + vue: 3.5.26 + + '@vue/shared@3.5.26': {} + + '@vueuse/core@10.11.1(vue@3.5.26)': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.26) + vue-demi: 0.14.10(vue@3.5.26) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/shared@10.11.1(vue@3.5.26)': + dependencies: + vue-demi: 0.14.10(vue@3.5.26) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + any-promise@1.3.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + arg@5.0.2: {} + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + autoprefixer@10.4.23(postcss@8.5.6): + dependencies: + browserslist: 4.28.1 + caniuse-lite: 1.0.30001764 + fraction.js: 5.3.4 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + baseline-browser-mapping@2.9.14: {} + + binary-extensions@2.3.0: {} + + birpc@2.9.0: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.14 + caniuse-lite: 1.0.30001764 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + camelcase-css@2.0.1: {} + + caniuse-lite@1.0.30001764: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + css-render@0.15.14: + dependencies: + '@emotion/hash': 0.8.0 + csstype: 3.0.11 + + cssesc@3.0.0: {} + + csstype@3.0.11: {} + + csstype@3.2.3: {} + + d3-color@3.1.0: {} + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-ease@3.0.1: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-selection@3.0.0: {} + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + + date-fns@4.1.0: {} + + delayed-stream@1.0.0: {} + + didyoumean@1.2.2: {} + + dlv@1.1.3: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + entities@7.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + escalade@3.2.0: {} + + estree-walker@2.0.2: {} + + evtd@0.2.4: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fraction.js@5.3.4: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + highlight.js@11.11.1: {} + + hookable@5.5.3: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-what@5.5.0: {} + + jiti@1.21.7: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + lodash-es@4.17.22: {} + + lodash@4.17.21: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mitt@3.0.1: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + naive-ui@2.43.2(vue@3.5.26): + dependencies: + '@css-render/plugin-bem': 0.15.14(css-render@0.15.14) + '@css-render/vue3-ssr': 0.15.14(vue@3.5.26) + '@types/katex': 0.16.8 + '@types/lodash': 4.17.23 + '@types/lodash-es': 4.17.12 + async-validator: 4.2.5 + css-render: 0.15.14 + csstype: 3.2.3 + date-fns: 4.1.0 + date-fns-tz: 3.2.0(date-fns@4.1.0) + evtd: 0.2.4 + highlight.js: 11.11.1 + lodash: 4.17.21 + lodash-es: 4.17.22 + seemly: 0.3.10 + treemate: 0.3.11 + vdirs: 0.1.8(vue@3.5.26) + vooks: 0.2.12(vue@3.5.26) + vue: 3.5.26 + vueuc: 0.4.65(vue@3.5.26) + + nanoid@3.3.11: {} + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + + object-hash@3.0.0: {} + + path-parse@1.0.7: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pify@2.3.0: {} + + pinia@3.0.4(vue@3.5.26): + dependencies: + '@vue/devtools-api': 7.7.9 + vue: 3.5.26 + + pirates@4.0.7: {} + + postcss-import@15.1.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.11 + + postcss-js@4.1.0(postcss@8.5.6): + dependencies: + camelcase-css: 2.0.1 + postcss: 8.5.6 + + postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 1.21.7 + postcss: 8.5.6 + + postcss-nested@6.2.0(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + postcss-selector-parser: 6.1.2 + + postcss-selector-parser@6.1.2: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + queue-microtask@1.2.3: {} + + read-cache@1.0.0: + dependencies: + pify: 2.3.0 + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + rfdc@1.4.1: {} + + rollup@4.55.1: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.55.1 + '@rollup/rollup-android-arm64': 4.55.1 + '@rollup/rollup-darwin-arm64': 4.55.1 + '@rollup/rollup-darwin-x64': 4.55.1 + '@rollup/rollup-freebsd-arm64': 4.55.1 + '@rollup/rollup-freebsd-x64': 4.55.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.55.1 + '@rollup/rollup-linux-arm-musleabihf': 4.55.1 + '@rollup/rollup-linux-arm64-gnu': 4.55.1 + '@rollup/rollup-linux-arm64-musl': 4.55.1 + '@rollup/rollup-linux-loong64-gnu': 4.55.1 + '@rollup/rollup-linux-loong64-musl': 4.55.1 + '@rollup/rollup-linux-ppc64-gnu': 4.55.1 + '@rollup/rollup-linux-ppc64-musl': 4.55.1 + '@rollup/rollup-linux-riscv64-gnu': 4.55.1 + '@rollup/rollup-linux-riscv64-musl': 4.55.1 + '@rollup/rollup-linux-s390x-gnu': 4.55.1 + '@rollup/rollup-linux-x64-gnu': 4.55.1 + '@rollup/rollup-linux-x64-musl': 4.55.1 + '@rollup/rollup-openbsd-x64': 4.55.1 + '@rollup/rollup-openharmony-arm64': 4.55.1 + '@rollup/rollup-win32-arm64-msvc': 4.55.1 + '@rollup/rollup-win32-ia32-msvc': 4.55.1 + '@rollup/rollup-win32-x64-gnu': 4.55.1 + '@rollup/rollup-win32-x64-msvc': 4.55.1 + fsevents: 2.3.3 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + seemly@0.3.10: {} + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.15 + ts-interface-checker: 0.1.13 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwindcss@3.4.19: + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.3 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.7 + lilconfig: 3.1.3 + micromatch: 4.0.8 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-import: 15.1.0(postcss@8.5.6) + postcss-js: 4.1.0(postcss@8.5.6) + postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6) + postcss-nested: 6.2.0(postcss@8.5.6) + postcss-selector-parser: 6.1.2 + resolve: 1.22.11 + sucrase: 3.35.1 + transitivePeerDependencies: + - tsx + - yaml + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + treemate@0.3.11: {} + + ts-interface-checker@0.1.13: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vdirs@0.1.8(vue@3.5.26): + dependencies: + evtd: 0.2.4 + vue: 3.5.26 + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.6 + rollup: 4.55.1 + optionalDependencies: + fsevents: 2.3.3 + + vooks@0.2.12(vue@3.5.26): + dependencies: + evtd: 0.2.4 + vue: 3.5.26 + + vue-demi@0.14.10(vue@3.5.26): + dependencies: + vue: 3.5.26 + + vue-router@4.6.4(vue@3.5.26): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.26 + + vue@3.5.26: + dependencies: + '@vue/compiler-dom': 3.5.26 + '@vue/compiler-sfc': 3.5.26 + '@vue/runtime-dom': 3.5.26 + '@vue/server-renderer': 3.5.26(vue@3.5.26) + '@vue/shared': 3.5.26 + + vueuc@0.4.65(vue@3.5.26): + dependencies: + '@css-render/vue3-ssr': 0.15.14(vue@3.5.26) + '@juggle/resize-observer': 3.4.0 + css-render: 0.15.14 + evtd: 0.2.4 + seemly: 0.3.10 + vdirs: 0.1.8(vue@3.5.26) + vooks: 0.2.12(vue@3.5.26) + vue: 3.5.26 diff --git a/web/canvas-app/postcss.config.js b/web/canvas-app/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/web/canvas-app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/web/canvas-app/public/skg-logo-black.svg b/web/canvas-app/public/skg-logo-black.svg new file mode 100644 index 0000000..ea381c6 --- /dev/null +++ b/web/canvas-app/public/skg-logo-black.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/canvas-app/src/App.vue b/web/canvas-app/src/App.vue new file mode 100644 index 0000000..b8028c2 --- /dev/null +++ b/web/canvas-app/src/App.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/web/canvas-app/src/api/chat.js b/web/canvas-app/src/api/chat.js new file mode 100644 index 0000000..36e96dc --- /dev/null +++ b/web/canvas-app/src/api/chat.js @@ -0,0 +1,35 @@ +/** + * Chat API | 对话 API + */ + +import { request } from '@/utils' + +// 对话补全 +export const chatCompletions = (data) => + request({ + url: `/chat/completions`, + method: 'post', + data + }) + +// 流式对话补全 +export const streamChatCompletions = async function* (data, signal, options = {}) { + const text = data?.messages?.at?.(-1)?.content || data?.goal || '' + const response = await fetch('/api/creative/copy', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ goal: typeof text === 'string' ? text : JSON.stringify(text), seconds: 15 }), + signal + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({})) + throw new Error(error?.detail || error?.message || '提示词助手请求失败') + } + + const json = await response.json() + const variant = json.variants?.[0] + yield variant?.image_prompt_en || variant?.video_prompt_en || '' +} diff --git a/web/canvas-app/src/api/image.js b/web/canvas-app/src/api/image.js new file mode 100644 index 0000000..36e687b --- /dev/null +++ b/web/canvas-app/src/api/image.js @@ -0,0 +1,17 @@ +/** + * Image API | 图片生成 API + */ + +import { request } from '@/utils' + +// 生成图片 +export const generateImage = (data, options = {}) => { + const { requestType = 'json', endpoint = '/images/generations' } = options + + return request({ + url: endpoint, + method: 'post', + data, + headers: requestType === 'formdata' ? { 'Content-Type': 'multipart/form-data' } : {} + }) +} diff --git a/web/canvas-app/src/api/index.js b/web/canvas-app/src/api/index.js new file mode 100644 index 0000000..b2ffaa0 --- /dev/null +++ b/web/canvas-app/src/api/index.js @@ -0,0 +1,8 @@ +/** + * API Index | API 索引 + * Simplified for open source version | 开源版简化版 + */ + +export * from './image' +export * from './video' +export * from './chat' diff --git a/web/canvas-app/src/api/model.js b/web/canvas-app/src/api/model.js new file mode 100644 index 0000000..d862be8 --- /dev/null +++ b/web/canvas-app/src/api/model.js @@ -0,0 +1,34 @@ +/** + * Model API | 模型 API + */ + +import { request } from '@/utils' + +// 分页查询模型列表 +export const getModelPage = (params) => + request({ + url: `/model/page`, + method: 'get', + params: { enable: true, size: 1000, current: 1, ...params } + }) + +// 根据类型获取模型列表 +export const getModelsByType = async (type) => { + const rsp = await getModelPage({ type, enable: true, size: 1000, current: 1 }) + return rsp?.data?.records || [] +} + +// 根据全称获取模型详情 +export const getModelByFullName = (fullName) => + request({ + url: `/model/fullName`, + method: 'get', + params: { fullName } + }) + +// 获取所有模型类型 +export const getModelTypes = () => + request({ + url: `/model/types`, + method: 'get' + }) diff --git a/web/canvas-app/src/api/video.js b/web/canvas-app/src/api/video.js new file mode 100644 index 0000000..51b6580 --- /dev/null +++ b/web/canvas-app/src/api/video.js @@ -0,0 +1,45 @@ +/** + * Video API | 视频生成 API + */ + +import { request } from '@/utils' + +// 创建视频任务 +export const createVideoTask = (data, options = {}) => { + const { endpoint = '/videos', requestType = 'json' } = options + return request({ + url: endpoint, + method: 'post', + data, + headers: requestType === 'formdata' + ? { 'Content-Type': 'multipart/form-data' } + : { 'Content-Type': 'application/json' } + }) +} + +// 查询视频任务状态 +export const getVideoTaskStatus = (taskId, options = {}) => { + const { endpoint = '/videos' } = options + return request({ + url: `${endpoint}`, + method: 'get' + }) +} +// 轮询视频任务直到完成 +export const pollVideoTask = async (taskId, maxAttempts = 120, interval = 5000) => { + for (let i = 0; i < maxAttempts; i++) { + const result = await getVideoTaskStatus(taskId) + + if (result.status === 'completed' || result.data) { + return result + } + + if (result.status === 'failed') { + throw new Error(result.error?.message || '视频生成失败') + } + + await new Promise(resolve => setTimeout(resolve, interval)) + } + + throw new Error('视频生成超时') +} diff --git a/web/canvas-app/src/assets/loading.webp b/web/canvas-app/src/assets/loading.webp new file mode 100644 index 0000000..567e9f7 Binary files /dev/null and b/web/canvas-app/src/assets/loading.webp differ diff --git a/web/canvas-app/src/assets/product01.jpg b/web/canvas-app/src/assets/product01.jpg new file mode 100644 index 0000000..7e7d703 Binary files /dev/null and b/web/canvas-app/src/assets/product01.jpg differ diff --git a/web/canvas-app/src/assets/scene01.jpeg b/web/canvas-app/src/assets/scene01.jpeg new file mode 100644 index 0000000..7899c6f Binary files /dev/null and b/web/canvas-app/src/assets/scene01.jpeg differ diff --git a/web/canvas-app/src/assets/shot01.jpeg b/web/canvas-app/src/assets/shot01.jpeg new file mode 100644 index 0000000..f5a6a58 Binary files /dev/null and b/web/canvas-app/src/assets/shot01.jpeg differ diff --git a/web/canvas-app/src/assets/workflow01.jpeg b/web/canvas-app/src/assets/workflow01.jpeg new file mode 100644 index 0000000..57dac43 Binary files /dev/null and b/web/canvas-app/src/assets/workflow01.jpeg differ diff --git a/web/canvas-app/src/assets/workflow02.jpeg b/web/canvas-app/src/assets/workflow02.jpeg new file mode 100644 index 0000000..f1a4f46 Binary files /dev/null and b/web/canvas-app/src/assets/workflow02.jpeg differ diff --git a/web/canvas-app/src/components/AppHeader.vue b/web/canvas-app/src/components/AppHeader.vue new file mode 100644 index 0000000..8ac714e --- /dev/null +++ b/web/canvas-app/src/components/AppHeader.vue @@ -0,0 +1,44 @@ + + + diff --git a/web/canvas-app/src/components/DownloadModal.vue b/web/canvas-app/src/components/DownloadModal.vue new file mode 100644 index 0000000..713b584 --- /dev/null +++ b/web/canvas-app/src/components/DownloadModal.vue @@ -0,0 +1,120 @@ + + + diff --git a/web/canvas-app/src/components/MentionsPicker.vue b/web/canvas-app/src/components/MentionsPicker.vue new file mode 100644 index 0000000..bcacd87 --- /dev/null +++ b/web/canvas-app/src/components/MentionsPicker.vue @@ -0,0 +1,350 @@ + + + + + diff --git a/web/canvas-app/src/components/WorkflowPanel.vue b/web/canvas-app/src/components/WorkflowPanel.vue new file mode 100644 index 0000000..da2ce28 --- /dev/null +++ b/web/canvas-app/src/components/WorkflowPanel.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/web/canvas-app/src/components/edges/ImageOrderEdge.vue b/web/canvas-app/src/components/edges/ImageOrderEdge.vue new file mode 100644 index 0000000..e4cfaaa --- /dev/null +++ b/web/canvas-app/src/components/edges/ImageOrderEdge.vue @@ -0,0 +1,150 @@ + + + diff --git a/web/canvas-app/src/components/edges/ImageRoleEdge.vue b/web/canvas-app/src/components/edges/ImageRoleEdge.vue new file mode 100644 index 0000000..68d4928 --- /dev/null +++ b/web/canvas-app/src/components/edges/ImageRoleEdge.vue @@ -0,0 +1,117 @@ + + + diff --git a/web/canvas-app/src/components/edges/PromptOrderEdge.vue b/web/canvas-app/src/components/edges/PromptOrderEdge.vue new file mode 100644 index 0000000..c86f140 --- /dev/null +++ b/web/canvas-app/src/components/edges/PromptOrderEdge.vue @@ -0,0 +1,123 @@ + + + diff --git a/web/canvas-app/src/components/nodes/ImageConfigNode.vue b/web/canvas-app/src/components/nodes/ImageConfigNode.vue new file mode 100644 index 0000000..69d4578 --- /dev/null +++ b/web/canvas-app/src/components/nodes/ImageConfigNode.vue @@ -0,0 +1,783 @@ + + + + + diff --git a/web/canvas-app/src/components/nodes/ImageNode.vue b/web/canvas-app/src/components/nodes/ImageNode.vue new file mode 100644 index 0000000..caa48dc --- /dev/null +++ b/web/canvas-app/src/components/nodes/ImageNode.vue @@ -0,0 +1,992 @@ + + + + + diff --git a/web/canvas-app/src/components/nodes/LLMConfigNode.vue b/web/canvas-app/src/components/nodes/LLMConfigNode.vue new file mode 100644 index 0000000..a8dbf28 --- /dev/null +++ b/web/canvas-app/src/components/nodes/LLMConfigNode.vue @@ -0,0 +1,1216 @@ + + + + + diff --git a/web/canvas-app/src/components/nodes/NodeHandleMenu.vue b/web/canvas-app/src/components/nodes/NodeHandleMenu.vue new file mode 100644 index 0000000..a6128b7 --- /dev/null +++ b/web/canvas-app/src/components/nodes/NodeHandleMenu.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/web/canvas-app/src/components/nodes/TextNode.vue b/web/canvas-app/src/components/nodes/TextNode.vue new file mode 100644 index 0000000..1084604 --- /dev/null +++ b/web/canvas-app/src/components/nodes/TextNode.vue @@ -0,0 +1,856 @@ + + + + + + diff --git a/web/canvas-app/src/components/nodes/VideoConfigNode.vue b/web/canvas-app/src/components/nodes/VideoConfigNode.vue new file mode 100644 index 0000000..61cd2b5 --- /dev/null +++ b/web/canvas-app/src/components/nodes/VideoConfigNode.vue @@ -0,0 +1,539 @@ + + + + + diff --git a/web/canvas-app/src/components/nodes/VideoNode.vue b/web/canvas-app/src/components/nodes/VideoNode.vue new file mode 100644 index 0000000..f94e7a9 --- /dev/null +++ b/web/canvas-app/src/components/nodes/VideoNode.vue @@ -0,0 +1,341 @@ + + + + + diff --git a/web/canvas-app/src/config/models.js b/web/canvas-app/src/config/models.js new file mode 100644 index 0000000..c2af530 --- /dev/null +++ b/web/canvas-app/src/config/models.js @@ -0,0 +1,118 @@ +/** + * SKG model and size configuration. + * These values mirror the backend /health capabilities and keep the canvas UI simple. + */ + +export const SKG_IMAGE_SIZE_OPTIONS = [ + { label: '自动', key: 'auto' }, + { label: '竖图 2:3', key: '1024x1536' }, + { label: '方图 1:1', key: '1024x1024' }, + { label: '横图 3:2', key: '1536x1024' } +] + +export const SKG_IMAGE_QUALITY_OPTIONS = [ + { label: '标准', key: 'standard' } +] + +export const SKG_VIDEO_SIZE_OPTIONS = [ + { label: '竖屏 9:16', key: '720x1280' }, + { label: '横屏 16:9', key: '1280x720' }, + { label: '方形 1:1', key: '1024x1024' }, + { label: '竖屏 3:4', key: '960x1280' } +] + +export const VIDEO_RATIO_LIST = SKG_VIDEO_SIZE_OPTIONS + +export const SEEDANCE_RESOLUTION_OPTIONS = [ + { label: '720p', key: '720p' }, + { label: '1080p', key: '1080p' } +] + +export const IMAGE_MODELS = [ + { + label: '自动', + key: 'auto', + provider: ['skg'], + sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key), + qualities: SKG_IMAGE_QUALITY_OPTIONS, + defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' } + }, + { + label: 'GPT Image 2', + key: 'gpt-image-2', + provider: ['skg'], + sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key), + qualities: SKG_IMAGE_QUALITY_OPTIONS, + defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' } + }, + { + label: 'Gemini 图片备用', + key: 'gemini-3-pro-image-preview', + provider: ['skg'], + sizes: SKG_IMAGE_SIZE_OPTIONS.map(s => s.key), + qualities: SKG_IMAGE_QUALITY_OPTIONS, + defaultParams: { size: '1024x1536', quality: 'standard', style: 'commercial' } + } +] + +export const VIDEO_MODELS = [ + { + label: 'Seedance', + key: 'seedance', + provider: ['skg'], + type: 't2v+i2v', + ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key), + durs: [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s })), + resolutions: ['720p', '1080p'], + defaultResolution: '1080p', + defaultParams: { ratio: '720x1280', duration: 10, resolution: '1080p' } + }, + { + label: 'Kling', + key: 'kling', + provider: ['skg'], + type: 't2v+i2v', + ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key), + durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })), + resolutions: ['720p'], + defaultResolution: '720p', + defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' } + }, + { + label: 'Veo 3', + key: 'veo3', + provider: ['skg'], + type: 't2v+i2v', + ratios: SKG_VIDEO_SIZE_OPTIONS.map(s => s.key), + durs: [4, 8, 12].map(s => ({ label: `${s} 秒`, key: s })), + resolutions: ['720p'], + defaultResolution: '720p', + defaultParams: { ratio: '720x1280', duration: 8, resolution: '720p' } + } +] + +export const CHAT_MODELS = [ + { label: 'SKG 提示词助手', key: 'skg-copy', provider: ['skg'] } +] + +export const IMAGE_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS +export const IMAGE_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS +export const IMAGE_STYLE_OPTIONS = [{ label: '商业营销', key: 'commercial' }] +export const VIDEO_RATIO_OPTIONS = SKG_VIDEO_SIZE_OPTIONS +export const VIDEO_DURATION_OPTIONS = [5, 8, 10, 12, 15].map(s => ({ label: `${s} 秒`, key: s })) + +export const SEEDREAM_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS +export const SEEDREAM_4K_SIZE_OPTIONS = SKG_IMAGE_SIZE_OPTIONS +export const SEEDREAM_QUALITY_OPTIONS = SKG_IMAGE_QUALITY_OPTIONS + +export const DEFAULT_IMAGE_MODEL = 'auto' +export const DEFAULT_VIDEO_MODEL = 'seedance' +export const DEFAULT_CHAT_MODEL = 'skg-copy' +export const DEFAULT_IMAGE_SIZE = '1024x1536' +export const DEFAULT_VIDEO_RATIO = '720x1280' +export const DEFAULT_VIDEO_DURATION = 10 + +export const getModelByName = (key) => { + const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS] + return allModels.find(m => m.key === key) +} diff --git a/web/canvas-app/src/config/providers.js b/web/canvas-app/src/config/providers.js new file mode 100644 index 0000000..bd43324 --- /dev/null +++ b/web/canvas-app/src/config/providers.js @@ -0,0 +1,40 @@ +/** + * SKG internal provider config. + * The browser never receives upstream model keys; all generation goes through /api. + */ + +export const PROVIDERS = { + skg: { + label: 'SKG 内部模型', + defaultBaseUrl: '/api', + endpoints: { + chat: '/creative/copy', + image: '/jobs/{jobId}/frames/{idx}/generate', + video: '/jobs/{jobId}/frames/{idx}/storyboard/video', + videoQuery: '/jobs/{jobId}' + }, + requestAdapter: { + chat: (params) => params, + image: (params) => params, + video: (params) => params + }, + responseAdapter: { + chat: (response) => response, + image: (response) => response, + video: (response) => response + } + }, + default: 'skg' +} + +export const getProviderList = () => ( + Object.entries(PROVIDERS) + .filter(([key]) => key !== 'default') + .map(([key, value]) => ({ key, label: value.label })) +) + +export const getDefaultProvider = () => PROVIDERS.default || 'skg' + +export const getProviderConfig = (provider) => PROVIDERS[provider] || PROVIDERS.skg + +export const getDefaultBaseUrl = (provider) => getProviderConfig(provider).defaultBaseUrl diff --git a/web/canvas-app/src/config/workflows.js b/web/canvas-app/src/config/workflows.js new file mode 100644 index 0000000..d76e948 --- /dev/null +++ b/web/canvas-app/src/config/workflows.js @@ -0,0 +1,90 @@ +/** + * SKG internal workflow templates. + */ + +const makeId = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(16).slice(2, 8)}` + +export const MULTI_ANGLE_PROMPTS = { + front: { + label: '正面', + english: 'Front View', + prompt: (subject) => `生成 SKG 营销图正面视角,主体清晰,产品佩戴或摆放关系准确,干净高级商业光线。\n主体参考: ${subject || '按上一张参考图保持一致'}` + }, + side: { + label: '侧面', + english: 'Side View', + prompt: (subject) => `生成 SKG 营销图侧面视角,保持主体和产品一致,肩颈或产品轮廓清楚,真实生活场景。\n主体参考: ${subject || '按上一张参考图保持一致'}` + }, + detail: { + label: '细节', + english: 'Detail View', + prompt: (subject) => `生成 SKG 产品细节视角,强调材质、佩戴方式、使用触点和高级感,无文字水印。\n主体参考: ${subject || '按上一张参考图保持一致'}` + } +} + +export const WORKFLOW_TEMPLATES = [ + { + id: 'skg-text-image-video', + name: '图文转视频', + description: '提示词 → 营销图 → 视频候选', + icon: 'VideocamOutline', + category: 'skg', + cover: '', + createNodes: (startPosition) => { + const textId = makeId('text') + const imageConfigId = makeId('image_config') + const imageId = makeId('image') + const videoConfigId = makeId('video_config') + const videoId = makeId('video') + const nodes = [ + { + id: textId, + type: 'text', + position: { x: startPosition.x, y: startPosition.y }, + data: { + label: '提示词', + content: '竖屏 SKG 短视频广告,真实办公室午休场景,人物佩戴 SKG 颈部按摩仪放松,产品形状清晰稳定,镜头缓慢推进,高级干净光线' + } + }, + { + id: imageConfigId, + type: 'imageConfig', + position: { x: startPosition.x + 380, y: startPosition.y }, + data: { label: '文生图', model: 'auto', size: '1024x1536' } + }, + { + id: imageId, + type: 'image', + position: { x: startPosition.x + 760, y: startPosition.y }, + data: { label: '营销图结果', url: '' } + }, + { + id: videoConfigId, + type: 'videoConfig', + position: { x: startPosition.x + 1140, y: startPosition.y }, + data: { label: '图生视频', model: 'seedance', ratio: '720x1280', dur: 10 } + }, + { + id: videoId, + type: 'video', + position: { x: startPosition.x + 1520, y: startPosition.y }, + data: { label: '视频结果', url: '' } + } + ] + const edges = [ + { id: makeId('edge'), source: textId, target: imageConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } }, + { id: makeId('edge'), source: imageConfigId, target: imageId, sourceHandle: 'right', targetHandle: 'left' }, + { id: makeId('edge'), source: textId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } }, + { id: makeId('edge'), source: imageId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'imageRole', data: { imageRole: 'first_frame_image' } }, + { id: makeId('edge'), source: videoConfigId, target: videoId, sourceHandle: 'right', targetHandle: 'left' } + ] + return { nodes, edges } + } + } +] + +export const getWorkflowById = (id) => WORKFLOW_TEMPLATES.find(w => w.id === id) + +export const getWorkflowsByCategory = (category) => WORKFLOW_TEMPLATES.filter(w => w.category === category) + +export default WORKFLOW_TEMPLATES diff --git a/web/canvas-app/src/hooks/index.js b/web/canvas-app/src/hooks/index.js new file mode 100644 index 0000000..e3f21e6 --- /dev/null +++ b/web/canvas-app/src/hooks/index.js @@ -0,0 +1,25 @@ +/** + * Hooks Entry | Hooks 入口 + * Exports all hooks for easy import + */ + +// API Configuration Hook | API 配置 Hook +export { useApiConfig } from './useApiConfig' + +// Model Configuration Hook | 模型配置 Hook +export { useModelConfig } from './useModelConfig' + +// Provider Hook | 渠道管理 Hook +export { useProvider } from './useProvider' + +// API Operation Hooks | API 操作 Hooks +export { + useApiState, + useChat, + useImageGeneration, + useVideoGeneration, + useApi +} from './useApi' + +// Workflow Orchestrator Hook | 工作流编排 Hook +export { useWorkflowOrchestrator } from './useWorkflowOrchestrator' diff --git a/web/canvas-app/src/hooks/useApi.js b/web/canvas-app/src/hooks/useApi.js new file mode 100644 index 0000000..bc1a70d --- /dev/null +++ b/web/canvas-app/src/hooks/useApi.js @@ -0,0 +1,299 @@ +import { ref, reactive, onUnmounted } from 'vue' + +const API_BASE = import.meta.env.VITE_SKG_API_BASE || '/api' + +const apiUrl = (path) => `${API_BASE}${path.startsWith('/') ? '' : '/'}${path}` + +const toAssetUrl = (path) => { + if (!path) return '' + if (/^(https?:|blob:|data:)/i.test(path)) return path + return apiUrl(path) +} + +const parseApiError = async (response, fallback) => { + const text = await response.text().catch(() => '') + try { + const parsed = JSON.parse(text) + return parsed?.detail || parsed?.error || fallback + } catch { + return text || fallback + } +} + +const requestJson = async (path, init = {}) => { + const response = await fetch(apiUrl(path), { + ...init, + headers: { + ...(init.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), + ...(init.headers || {}) + } + }) + if (!response.ok) { + throw new Error(await parseApiError(response, `${path} ${response.status}`)) + } + return response.json() +} + +const dataUrlToFile = (dataUrl, filename = 'reference.jpg') => { + const [meta, payload] = dataUrl.split(',') + const mime = /data:([^;]+)/.exec(meta)?.[1] || 'image/jpeg' + const binary = atob(payload || '') + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) + return new File([bytes], filename, { type: mime }) +} + +const imageSourceToFile = async (source, filename = 'reference.jpg') => { + if (!source) return null + if (source instanceof File) return source + if (typeof source !== 'string') return null + if (source.startsWith('data:')) return dataUrlToFile(source, filename) + const url = source.startsWith('/jobs/') ? apiUrl(source) : source + const response = await fetch(url) + if (!response.ok) throw new Error(`读取参考图失败 ${response.status}`) + const blob = await response.blob() + return new File([blob], filename, { type: blob.type || 'image/jpeg' }) +} + +const createCreativeImageJob = async (file = null) => { + if (file) { + const form = new FormData() + form.append('file', file) + return requestJson('/creative/jobs/image', { method: 'POST', body: form }) + } + return requestJson('/creative/jobs/image', { method: 'POST', body: JSON.stringify({}) }) +} + +const uploadReferenceFrame = async (jobId, file) => { + const form = new FormData() + form.append('file', file) + return requestJson(`/jobs/${jobId}/frames/upload`, { method: 'POST', body: form }) +} + +const newestGeneratedImage = (job, frameIdx = 0) => { + const frame = (job.frames || []).find(item => item.index === frameIdx) || job.frames?.[0] + return [...(frame?.generated_images || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] +} + +const newestGeneratedVideo = (job) => ( + [...(job.generated_videos || [])].sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0] +) + +const normalizeVideoSize = (value) => { + const raw = String(value || '').trim().toLowerCase() + const map = { + '9:16': '720x1280', + '9x16': '720x1280', + 'vertical': '720x1280', + 'portrait': '720x1280', + '16:9': '1280x720', + '16x9': '1280x720', + 'horizontal': '1280x720', + 'landscape': '1280x720', + '1:1': '1024x1024', + '1x1': '1024x1024', + '3:4': '960x1280', + '3x4': '960x1280' + } + if (/^\d+x\d+$/.test(raw)) return raw + return map[raw] || '720x1280' +} + +export const useApiState = () => { + const loading = ref(false) + const error = ref(null) + const status = ref('idle') + + const reset = () => { + loading.value = false + error.value = null + status.value = 'idle' + } + const setLoading = (isLoading) => { + loading.value = isLoading + status.value = isLoading ? 'running' : status.value + } + const setError = (err) => { + error.value = err + status.value = 'error' + loading.value = false + } + const setSuccess = () => { + status.value = 'success' + loading.value = false + error.value = null + } + + return { loading, error, status, reset, setLoading, setError, setSuccess } +} + +export const useChat = () => { + const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState() + const messages = ref([]) + const currentResponse = ref('') + let stopped = false + + const send = async (content) => { + setLoading(true) + stopped = false + try { + const response = await requestJson('/creative/copy', { + method: 'POST', + body: JSON.stringify({ goal: content, seconds: 15 }) + }) + const variant = response.variants?.[0] + const result = variant?.image_prompt_en || variant?.video_prompt_en || content + if (!stopped) { + currentResponse.value = result + messages.value.push({ role: 'user', content }) + messages.value.push({ role: 'assistant', content: result }) + } + setSuccess() + return result + } catch (err) { + setError(err) + throw err + } + } + + const stop = () => { + stopped = true + } + const clear = () => { + messages.value = [] + currentResponse.value = '' + reset() + } + onUnmounted(() => stop()) + return { loading, error, status, messages, currentResponse, send, stop, clear, reset } +} + +export const useImageGeneration = () => { + const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState() + const images = ref([]) + const currentImage = ref(null) + + const generate = async (params) => { + setLoading(true) + images.value = [] + currentImage.value = null + try { + const refs = Array.isArray(params.image) ? params.image : (params.image ? [params.image] : []) + const firstRef = refs[0] ? await imageSourceToFile(refs[0], 'image-reference.jpg') : null + const job = await createCreativeImageJob(firstRef) + const updated = await requestJson(`/jobs/${job.id}/frames/0/generate`, { + method: 'POST', + body: JSON.stringify({ + prompt: params.prompt || '', + model: params.model || 'auto', + size: params.size || '1024x1536', + mode: firstRef ? 'edit' : 'text' + }) + }) + const generated = newestGeneratedImage(updated, 0) + if (!generated?.url) throw new Error('图片生成完成但未返回地址') + const result = [{ ...generated, url: toAssetUrl(generated.url), jobId: updated.id, frameIdx: 0 }] + images.value = result + currentImage.value = result[0] + setSuccess() + return result + } catch (err) { + setError(err) + throw err + } + } + + return { loading, error, status, images, currentImage, generate, reset } +} + +export const useVideoGeneration = () => { + const { loading, error, status, reset, setLoading, setError, setSuccess } = useApiState() + const video = ref(null) + const taskId = ref(null) + const progress = reactive({ attempt: 0, maxAttempts: 180, percentage: 0 }) + + const createVideoTaskOnly = async (params) => { + setLoading(true) + try { + const firstFile = params.first_frame_image ? await imageSourceToFile(params.first_frame_image, 'first-frame.jpg') : null + let job = await createCreativeImageJob(firstFile) + let lastFrameIdx = null + if (params.last_frame_image) { + const lastFile = await imageSourceToFile(params.last_frame_image, 'last-frame.jpg') + if (lastFile) { + job = await uploadReferenceFrame(job.id, lastFile) + lastFrameIdx = Math.max(...(job.frames || []).map(frame => frame.index)) + } + } + const updated = await requestJson(`/jobs/${job.id}/frames/0/storyboard/video`, { + method: 'POST', + body: JSON.stringify({ + prompt: params.prompt || '', + duration: Number(params.dur || params.duration || params.seconds || 10), + count: 1, + first_image: firstFile ? { kind: 'keyframe', frame_idx: 0 } : null, + last_image: lastFrameIdx !== null ? { kind: 'keyframe', frame_idx: lastFrameIdx } : null, + model: params.model || 'seedance', + size: normalizeVideoSize(params.ratio || params.size) + }) + }) + const created = newestGeneratedVideo(updated) + if (!created?.id) throw new Error('视频任务已提交但未返回任务编号') + const id = `skg:${updated.id}:${created.id}` + taskId.value = id + status.value = 'polling' + setSuccess() + return { taskId: id } + } catch (err) { + setError(err) + throw err + } + } + + const pollVideoTask = async (pollTaskId, onProgress = () => {}) => { + const match = /^skg:([^:]+):([^:]+)$/.exec(String(pollTaskId || '')) + if (!match) throw new Error('未知视频任务类型') + const [, jobId, videoId] = match + const maxAttempts = 180 + const interval = 5000 + + for (let i = 0; i < maxAttempts; i += 1) { + const job = await requestJson(`/jobs/${jobId}`, { method: 'GET' }) + const item = (job.generated_videos || []).find(v => v.id === videoId) + if (!item) throw new Error('视频任务不存在') + const percentage = item.progress || Math.min(Math.round((i / maxAttempts) * 100), 98) + onProgress(i + 1, percentage) + progress.attempt = i + 1 + progress.percentage = percentage + if (item.status === 'completed') { + const result = { ...item, url: toAssetUrl(item.url || `/jobs/${jobId}/storyboard-videos/${videoId}.mp4`) } + video.value = result + setSuccess() + return result + } + if (item.status === 'failed') { + throw new Error(item.error || '视频生成失败') + } + await new Promise(resolve => setTimeout(resolve, interval)) + } + throw new Error('视频生成超时') + } + + const generate = async (params) => { + const { taskId: newTaskId, url } = await createVideoTaskOnly(params) + if (url) { + video.value = { url } + return video.value + } + return pollVideoTask(newTaskId) + } + + return { loading, error, status, video, taskId, progress, generate, reset, createVideoTaskOnly, pollVideoTask } +} + +export const useApi = () => { + const chat = useChat() + const image = useImageGeneration() + const videoGen = useVideoGeneration() + return { config: {}, chat, image, video: videoGen } +} diff --git a/web/canvas-app/src/hooks/useApiConfig.js b/web/canvas-app/src/hooks/useApiConfig.js new file mode 100644 index 0000000..05a4c14 --- /dev/null +++ b/web/canvas-app/src/hooks/useApiConfig.js @@ -0,0 +1,26 @@ +import { computed, ref } from 'vue' + +/** + * Internal auth/session config. + * Upstream model credentials stay on the server and are not configured in this UI. + */ +export const useApiConfig = () => { + const apiKey = ref('internal-session') + const baseUrl = ref('/api') + const isConfigured = computed(() => true) + + const setApiKey = () => {} + const setBaseUrl = () => {} + const configure = () => {} + const clear = () => {} + + return { + apiKey, + baseUrl, + isConfigured, + setApiKey, + setBaseUrl, + configure, + clear + } +} diff --git a/web/canvas-app/src/hooks/useModelConfig.js b/web/canvas-app/src/hooks/useModelConfig.js new file mode 100644 index 0000000..09ad4e7 --- /dev/null +++ b/web/canvas-app/src/hooks/useModelConfig.js @@ -0,0 +1,433 @@ +/** + * Model Config Hook | 模型配置 Hook + * Manages model configuration with local storage persistence + */ + +import { ref, computed, watch } from 'vue' +import { STORAGE_KEYS } from '@/utils' +import { useProvider } from './useProvider' +import { + CHAT_MODELS, + IMAGE_MODELS, + VIDEO_MODELS, + DEFAULT_CHAT_MODEL, + DEFAULT_IMAGE_MODEL, + DEFAULT_VIDEO_MODEL +} from '@/config/models' + +/** + * 检查模型是否支持指定渠道 + * @param {Object} model - 模型配置 + * @param {string} provider - 渠道名称 + * @returns {boolean} 是否支持 + */ +const isModelSupported = (model, provider) => { + // 如果没有 provider 字段,默认支持所有渠道 + if (!model.provider) { + return true + } + // 如果有 provider 字段,检查是否包含指定渠道 + return model.provider.includes(provider) +} + +/** + * Get stored JSON value from localStorage + */ +const getStoredJson = (key, defaultValue = []) => { + try { + const stored = localStorage.getItem(key) + return stored ? JSON.parse(stored) : defaultValue + } catch { + return defaultValue + } +} + +/** + * Set stored JSON value to localStorage + */ +const setStoredJson = (key, value) => { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // Ignore storage errors + } +} + +/** + * Get stored string value from localStorage + */ +const getStored = (key, defaultValue = '') => { + try { + return localStorage.getItem(key) || defaultValue + } catch { + return defaultValue + } +} + +/** + * Set stored string value to localStorage + */ +const setStored = (key, value) => { + try { + if (value) { + localStorage.setItem(key, value) + } else { + localStorage.removeItem(key) + } + } catch { + // Ignore storage errors + } +} + +// Shared reactive state (singleton pattern) +const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, [])) +const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, [])) +const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, [])) + +// 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] } +const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider', {})) +const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider', {})) +const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider', {})) + +const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL)) +const selectedImageModel = ref(getStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL)) +const selectedVideoModel = ref(getStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL)) + +/** + * Model Configuration Hook + */ +export const useModelConfig = () => { + // Get current provider | 获取当前渠道 + const { currentProvider } = useProvider() + + // Combined models (built-in + custom, including provider-specific custom models) + const allChatModels = computed(() => [ + ...CHAT_MODELS.map(m => ({ ...m, isCustom: false })), + ...customChatModels.value.map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true + })), + // 添加当前渠道的自定义模型 + ...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + provider: [currentProvider.value] + })) + ]) + + const allImageModels = computed(() => [ + ...IMAGE_MODELS.map(m => ({ ...m, isCustom: false })), + ...customImageModels.value.map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + sizes: [], + defaultParams: { quality: 'standard', style: 'vivid' } + })), + // 添加当前渠道的自定义模型 + ...(customImageModelsByProvider.value[currentProvider.value] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + sizes: [], + defaultParams: { quality: 'standard', style: 'vivid' }, + provider: [currentProvider.value] + })) + ]) + + const allVideoModels = computed(() => [ + ...VIDEO_MODELS.map(m => ({ ...m, isCustom: false })), + ...customVideoModels.value.map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + ratios: ['16x9', '9:16', '1:1'], + durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }], + defaultParams: { ratio: '16:9', duration: 5 } + })), + // 添加当前渠道的自定义模型 + ...(customVideoModelsByProvider.value[currentProvider.value] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + ratios: ['16x9', '9:16', '1:1'], + durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }], + defaultParams: { ratio: '16:9', duration: 5 }, + provider: [currentProvider.value] + })) + ]) + + // Available models filtered by provider | 根据渠道过滤的可用模型 + const availableChatModels = computed(() => + allChatModels.value.filter(m => isModelSupported(m, currentProvider.value)) + ) + + const availableImageModels = computed(() => + allImageModels.value.filter(m => isModelSupported(m, currentProvider.value)) + ) + + const availableVideoModels = computed(() => + allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value)) + ) + + // All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤) + const allAvailableChatModels = computed(() => allChatModels.value) + const allAvailableImageModels = computed(() => allImageModels.value) + const allAvailableVideoModels = computed(() => allVideoModels.value) + + // 获取指定渠道的模型(包括内置 + 该渠道自定义) + const getModelsByProvider = (provider) => { + const chat = [ + ...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), + ...(customChatModelsByProvider.value[provider] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + provider: [provider] + })) + ] + const image = [ + ...IMAGE_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), + ...(customImageModelsByProvider.value[provider] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + sizes: [], + defaultParams: { quality: 'standard', style: 'vivid' }, + provider: [provider] + })) + ] + const video = [ + ...VIDEO_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), + ...(customVideoModelsByProvider.value[provider] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + ratios: ['16x9', '9:16', '1:1'], + durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }], + defaultParams: { ratio: '16:9', duration: 5 }, + provider: [provider] + })) + ] + return { chat, image, video } + } + + // Watch and persist changes + watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true }) + watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true }) + watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true }) + + // Watch and persist by provider changes + watch(customChatModelsByProvider, (val) => { + const key = STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER || 'custom-chat-models-by-provider' + setStoredJson(key, val) + }, { deep: true }) + watch(customImageModelsByProvider, (val) => { + const key = STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER || 'custom-image-models-by-provider' + setStoredJson(key, val) + }, { deep: true }) + watch(customVideoModelsByProvider, (val) => { + const key = STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER || 'custom-video-models-by-provider' + setStoredJson(key, val) + }, { deep: true }) + + watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val)) + watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val)) + watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val)) + + // Add custom model + const addCustomChatModel = (modelKey, label = '') => { + if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false + customChatModels.value.push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomImageModel = (modelKey, label = '') => { + if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false + customImageModels.value.push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomVideoModel = (modelKey, label = '') => { + if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false + customVideoModels.value.push({ key: modelKey, label: label || modelKey }) + return true + } + + // Remove custom model + const removeCustomChatModel = (modelKey) => { + const idx = customChatModels.value.findIndex(m => m.key === modelKey) + if (idx > -1) { + customChatModels.value.splice(idx, 1) + if (selectedChatModel.value === modelKey) { + selectedChatModel.value = DEFAULT_CHAT_MODEL + } + return true + } + return false + } + + const removeCustomImageModel = (modelKey) => { + const idx = customImageModels.value.findIndex(m => m.key === modelKey) + if (idx > -1) { + customImageModels.value.splice(idx, 1) + if (selectedImageModel.value === modelKey) { + selectedImageModel.value = DEFAULT_IMAGE_MODEL + } + return true + } + return false + } + + const removeCustomVideoModel = (modelKey) => { + const idx = customVideoModels.value.findIndex(m => m.key === modelKey) + if (idx > -1) { + customVideoModels.value.splice(idx, 1) + if (selectedVideoModel.value === modelKey) { + selectedVideoModel.value = DEFAULT_VIDEO_MODEL + } + return true + } + return false + } + + // 按渠道添加自定义模型 + const addCustomChatModelByProvider = (modelKey, provider, label = '') => { + if (!modelKey) return false + if (!customChatModelsByProvider.value[provider]) { + customChatModelsByProvider.value[provider] = [] + } + if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false + customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomImageModelByProvider = (modelKey, provider, label = '') => { + if (!modelKey) return false + if (!customImageModelsByProvider.value[provider]) { + customImageModelsByProvider.value[provider] = [] + } + if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false + customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomVideoModelByProvider = (modelKey, provider, label = '') => { + if (!modelKey) return false + if (!customVideoModelsByProvider.value[provider]) { + customVideoModelsByProvider.value[provider] = [] + } + if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false + customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) + return true + } + + // 按渠道删除自定义模型 + const removeCustomChatModelByProvider = (modelKey, provider) => { + if (!customChatModelsByProvider.value[provider]) return false + const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey) + if (idx > -1) { + customChatModelsByProvider.value[provider].splice(idx, 1) + return true + } + return false + } + + const removeCustomImageModelByProvider = (modelKey, provider) => { + if (!customImageModelsByProvider.value[provider]) return false + const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey) + if (idx > -1) { + customImageModelsByProvider.value[provider].splice(idx, 1) + return true + } + return false + } + + const removeCustomVideoModelByProvider = (modelKey, provider) => { + if (!customVideoModelsByProvider.value[provider]) return false + const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey) + if (idx > -1) { + customVideoModelsByProvider.value[provider].splice(idx, 1) + return true + } + return false + } + + // Get model by key + const getChatModel = (key) => allChatModels.value.find(m => m.key === key) + const getImageModel = (key) => allImageModels.value.find(m => m.key === key) + const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key) + + // Clear all custom models + const clearCustomModels = () => { + customChatModels.value = [] + customImageModels.value = [] + customVideoModels.value = [] + selectedChatModel.value = DEFAULT_CHAT_MODEL + selectedImageModel.value = DEFAULT_IMAGE_MODEL + selectedVideoModel.value = DEFAULT_VIDEO_MODEL + } + + return { + // All models (built-in + custom) + allChatModels, + allImageModels, + allVideoModels, + + // Available models filtered by provider | 根据渠道过滤的可用模型 + availableChatModels, + availableImageModels, + availableVideoModels, + + // All models (including models from all providers, not filtered) | 所有模型(不按渠道过滤) + allAvailableChatModels, + allAvailableImageModels, + allAvailableVideoModels, + + // Custom models only + customChatModels, + customImageModels, + customVideoModels, + + // Selected models + selectedChatModel, + selectedImageModel, + selectedVideoModel, + + // Add methods + addCustomChatModel, + addCustomImageModel, + addCustomVideoModel, + + // Remove methods + removeCustomChatModel, + removeCustomImageModel, + removeCustomVideoModel, + + // Get model + getChatModel, + getImageModel, + getVideoModel, + + // Get models by provider + getModelsByProvider, + + // Custom models by provider + customChatModelsByProvider, + customImageModelsByProvider, + customVideoModelsByProvider, + + // Add/Remove by provider methods + addCustomChatModelByProvider, + addCustomImageModelByProvider, + addCustomVideoModelByProvider, + removeCustomChatModelByProvider, + removeCustomImageModelByProvider, + removeCustomVideoModelByProvider, + + // Clear + clearCustomModels + } +} diff --git a/web/canvas-app/src/hooks/useNodeRef.js b/web/canvas-app/src/hooks/useNodeRef.js new file mode 100644 index 0000000..bc05610 --- /dev/null +++ b/web/canvas-app/src/hooks/useNodeRef.js @@ -0,0 +1,103 @@ +/** + * 节点引用解析 Hook + * 用于解析文本中的 @[nodeId] 引用格式 + */ + +/** + * 解析文本中的 @ 引用 + * @param {string} text - 待解析的文本 + * @returns {Array<{nodeId: string, name?: string, order: number}>} 解析出的引用列表 + */ +export function parseMentions(text) { + if (!text) return [] + + const mentions = [] + // 匹配 @[nodeId] 或 @[nodeId|name] 格式 + const regex = /@\[([^\]|]+)(?:\|([^\]]+))?\]/g + let match + let order = 0 + + while ((match = regex.exec(text)) !== null) { + mentions.push({ + nodeId: match[1], + name: match[2] || null, + order: order++ + }) + } + + return mentions +} + +/** + * 检查文本是否包含对指定节点的 @ 引用 + * @param {string} text - 待检查的文本 + * @param {string} nodeId - 节点ID + * @returns {boolean} 是否包含引用 + */ +export function hasMention(text, nodeId) { + const mentions = parseMentions(text) + return mentions.some(m => m.nodeId === nodeId) +} + +/** + * 从文本中提取对指定节点的引用 + * @param {string} text - 待解析的文本 + * @param {string} nodeId - 节点ID + * @returns {Array<{nodeId: string, name?: string, order: number}>} 匹配的引用 + */ +export function getMentionsToNode(text, nodeId) { + const mentions = parseMentions(text) + return mentions.filter(m => m.nodeId === nodeId) +} + +/** + * 清理文本中的 @ 引用标记,保留引用名称(如果有) + * @param {string} text - 待清理的文本 + * @param {string} placeholder - 替换引用的占位符,默认空字符串 + * @returns {string} 清理后的文本 + */ +export function cleanMentions(text, placeholder = '') { + if (!text) return '' + return text.replace(/@\[([^\]|]+)(?:\|([^\]]+))?\]/g, (_, nodeId, name) => { + return name || placeholder + }) +} + +/** + * 在文本中插入 @ 引用 + * @param {string} text - 原文本 + * @param {string} nodeId - 节点ID + * @param {string} name - 显示名称(可选) + * @param {number} position - 插入位置(默认末尾) + * @returns {string} 插入引用后的文本 + */ +export function insertMention(text, nodeId, name = null, position = -1) { + const mention = name ? `@[${nodeId}|${name}]` : `@[${nodeId}]` + + if (position < 0 || position >= text.length) { + return text + mention + } + + return text.slice(0, position) + mention + text.slice(position) +} + +/** + * 从文本中移除指定节点的 @ 引用 + * @param {string} text - 原文本 + * @param {string} nodeId - 节点ID + * @returns {string} 移除引用后的文本 + */ +export function removeMention(text, nodeId) { + if (!text) return '' + return text.replace(new RegExp(`@\\[${nodeId}(?:\\|[^\\]]+)?\\]`, 'g'), '') +} + +/** + * 获取文本中所有 @ 引用的节点ID列表(去重) + * @param {string} text - 待解析的文本 + * @returns {string[]} 节点ID列表 + */ +export function getMentionedNodeIds(text) { + const mentions = parseMentions(text) + return [...new Set(mentions.map(m => m.nodeId))] +} diff --git a/web/canvas-app/src/hooks/useProvider.js b/web/canvas-app/src/hooks/useProvider.js new file mode 100644 index 0000000..327893d --- /dev/null +++ b/web/canvas-app/src/hooks/useProvider.js @@ -0,0 +1,124 @@ +/** + * Provider Hook | 渠道管理 Hook + * 管理当前选中的 API 渠道,提供请求/响应适配功能 + */ + +import { ref, computed } from 'vue' +import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig } from '@/config/providers' + +// 存储键名 +const STORAGE_KEY = 'api-provider' + +/** + * Get stored value from localStorage | 从 localStorage 获取存储值 + */ +const getStored = (key, defaultValue = '') => { + try { + return localStorage.getItem(key) || defaultValue + } catch { + return defaultValue + } +} + +/** + * Set stored value to localStorage | 设置存储值到 localStorage + */ +const setStored = (key, value) => { + try { + localStorage.setItem(key, value) + } catch { + // ignore + } +} + +/** + * Remove stored value from localStorage | 从 localStorage 移除存储值 + */ +const removeStored = (key) => { + try { + localStorage.removeItem(key) + } catch { + // ignore + } +} + +/** + * 获取存储的渠道 + */ +const getStoredProvider = () => { + return getStored(STORAGE_KEY) +} + +/** + * Provider Hook | 渠道管理 Hook + */ +export const useProvider = () => { + // 当前选中的渠道 + const currentProvider = ref(getStoredProvider() || getDefaultProvider()) + + // 渠道列表 + const providerList = getProviderList() + + // 当前渠道配置 + const providerConfig = computed(() => getProviderConfig(currentProvider.value)) + + // 当前渠道标签 + const providerLabel = computed(() => providerConfig.value.label || currentProvider.value) + + /** + * 设置当前渠道 + */ + const setProvider = (provider) => { + if (PROVIDERS[provider]) { + currentProvider.value = provider + setStored(STORAGE_KEY, provider) + } + } + + /** + * 清除渠道配置 + */ + const clearProvider = () => { + currentProvider.value = getDefaultProvider() + removeStored(STORAGE_KEY) + } + + /** + * 适配请求参数 + * @param {string} type - 请求类型:'chat' | 'image' | 'video' + * @param {Object} params - 原始请求参数 + */ + const adaptRequest = (type, params) => { + const config = providerConfig.value + if (config.requestAdapter && config.requestAdapter[type]) { + return config.requestAdapter[type](params) + } + // 如果没有适配器,返回原始参数 + return params + } + + /** + * 适配响应数据 + * @param {string} type - 响应类型:'chat' | 'image' | 'video' + * @param {Object} response - 原始响应数据 + */ + const adaptResponse = (type, response) => { + const config = providerConfig.value + if (config.responseAdapter && config.responseAdapter[type]) { + return config.responseAdapter[type](response) + } + // 如果没有适配器,返回原始响应 + return response + } + + return { + currentProvider, + providerList, + providerConfig, + providerLabel, + setProvider, + clearProvider, + adaptRequest, + adaptResponse + } +} diff --git a/web/canvas-app/src/hooks/useWorkflowOrchestrator.js b/web/canvas-app/src/hooks/useWorkflowOrchestrator.js new file mode 100644 index 0000000..d76cfde --- /dev/null +++ b/web/canvas-app/src/hooks/useWorkflowOrchestrator.js @@ -0,0 +1,86 @@ +import { ref } from 'vue' +import { addNode, addEdge, updateNode } from '@/stores/canvas' + +const WORKFLOW_TYPES = { + TEXT_TO_IMAGE: 'text_to_image', + TEXT_TO_IMAGE_TO_VIDEO: 'text_to_image_to_video', + STORYBOARD: 'storyboard' +} + +export const useWorkflowOrchestrator = () => { + const isAnalyzing = ref(false) + const isExecuting = ref(false) + const currentStep = ref(0) + const totalSteps = ref(0) + const executionLog = ref([]) + + const addLog = (type, message) => { + executionLog.value.push({ type, message, timestamp: Date.now() }) + } + + const analyzeIntent = async (userInput) => { + const text = String(userInput || '') + const wantsVideo = /视频|动起来|镜头|运动|video|motion/i.test(text) + return { + workflow_type: wantsVideo ? WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO : WORKFLOW_TYPES.TEXT_TO_IMAGE, + description: wantsVideo ? 'SKG 文生视频' : 'SKG 文生图', + image_prompt: text, + video_prompt: text + } + } + + const executeWorkflow = async (params, position = { x: 100, y: 100 }) => { + isExecuting.value = true + currentStep.value = 1 + totalSteps.value = params.workflow_type === WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO ? 3 : 2 + try { + const textId = addNode('text', position, { + label: '提示词', + content: params.image_prompt || params.video_prompt || '' + }) + if (params.workflow_type === WORKFLOW_TYPES.TEXT_TO_IMAGE_TO_VIDEO) { + const videoConfigId = addNode('videoConfig', { x: position.x + 400, y: position.y }, { + label: '文生视频', + autoExecute: true + }) + addEdge({ source: textId, target: videoConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } }) + updateNode(videoConfigId, { autoExecute: true }) + } else { + const imageConfigId = addNode('imageConfig', { x: position.x + 400, y: position.y }, { + label: '文生图', + autoExecute: true + }) + addEdge({ source: textId, target: imageConfigId, sourceHandle: 'right', targetHandle: 'left', type: 'promptOrder', data: { promptOrder: 1 } }) + updateNode(imageConfigId, { autoExecute: true }) + } + addLog('success', 'SKG 工作流已启动') + } finally { + isExecuting.value = false + } + } + + const createTextToImageWorkflow = (imagePrompt, position) => executeWorkflow({ + workflow_type: WORKFLOW_TYPES.TEXT_TO_IMAGE, + image_prompt: imagePrompt + }, position) + + const createMultiAngleStoryboard = (characterDescription, position) => executeWorkflow({ + workflow_type: WORKFLOW_TYPES.STORYBOARD, + image_prompt: `SKG 多角度营销分镜,主体要求:${characterDescription || '保持主体和产品一致'}` + }, position) + + return { + isAnalyzing, + isExecuting, + currentStep, + totalSteps, + executionLog, + analyzeIntent, + executeWorkflow, + createTextToImageWorkflow, + createMultiAngleStoryboard, + WORKFLOW_TYPES + } +} + +export default useWorkflowOrchestrator diff --git a/web/canvas-app/src/main.js b/web/canvas-app/src/main.js new file mode 100644 index 0000000..1e9352d --- /dev/null +++ b/web/canvas-app/src/main.js @@ -0,0 +1,15 @@ +/** + * Main entry point | 主入口 + */ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './style.css' + +const app = createApp(App) +const pinia = createPinia() + +app.use(pinia) +app.use(router) +app.mount('#app') diff --git a/web/canvas-app/src/router/index.js b/web/canvas-app/src/router/index.js new file mode 100644 index 0000000..9eca13d --- /dev/null +++ b/web/canvas-app/src/router/index.js @@ -0,0 +1,24 @@ +/** + * Router configuration | 路由配置 + */ +import { createRouter, createWebHistory } from 'vue-router' + +const routes = [ + { + path: '/', + name: 'Home', + component: () => import('../views/Home.vue') + }, + { + path: '/p/:id?', + name: 'Canvas', + component: () => import('../views/Canvas.vue') + } +] + +const router = createRouter({ + history: createWebHistory('/canvas/'), + routes +}) + +export default router diff --git a/web/canvas-app/src/stores/api.js b/web/canvas-app/src/stores/api.js new file mode 100644 index 0000000..16f9108 --- /dev/null +++ b/web/canvas-app/src/stores/api.js @@ -0,0 +1,10 @@ +/** + * API Store | API 状态存储 + * Pure global state - internal session config lives in hooks/useApiConfig.js + * 纯全局状态 - 内部会话配置位于 hooks/useApiConfig.js + */ + +// Re-export from hook for backward compatibility | 为向后兼容重新导出 +export { useApiConfig } from '../hooks/useApiConfig' + +// For components that need direct access to config state | 用于需要直接访问配置状态的组件 diff --git a/web/canvas-app/src/stores/canvas.js b/web/canvas-app/src/stores/canvas.js new file mode 100644 index 0000000..d37a2fe --- /dev/null +++ b/web/canvas-app/src/stores/canvas.js @@ -0,0 +1,559 @@ +/** + * Canvas store | 画布状态管理 + * Manages nodes, edges and canvas state + */ +import { ref, watch } from 'vue' +import { updateProjectCanvas, getProjectCanvas } from './projects' +import { IMAGE_MODELS, VIDEO_MODELS, CHAT_MODELS, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, DEFAULT_CHAT_MODEL } from '../config/models' + +// Node ID counter | 节点ID计数器 +let nodeId = 0 +const getNodeId = () => `node_${nodeId++}` + +// Current project ID | 当前项目ID +export const currentProjectId = ref(null) + +// Nodes and edges | 节点和边 +export const nodes = ref([]) +export const edges = ref([]) + +// Viewport state | 视口状态 +export const canvasViewport = ref({ x: 100, y: 50, zoom: 0.8 }) + +// Selected node | 选中的节点 +export const selectedNode = ref(null) + +// Auto-save flag | 自动保存标志 +let autoSaveEnabled = false +let saveTimeout = null + +// History for undo/redo | 撤销/重做历史 +const history = ref([]) +const historyIndex = ref(-1) +const MAX_HISTORY = 50 +let isRestoring = false + +// Position change threshold for history | 位置变化阈值 +const POSITION_THRESHOLD = 10 + +// Batch operation tracking | 批量操作跟踪 +let isBatchOperation = false +let batchStartState = null + +/** + * Save current state to history | 保存当前状态到历史 + * @param {boolean} force - Force save even if batch operation | 强制保存,即使在批量操作中 + */ +const saveToHistory = (force = false) => { + if (isRestoring) return + + // If in batch operation and not forced, don't save | 如果在批量操作中且未强制保存,则不保存 + if (isBatchOperation && !force) return + + const state = { + nodes: JSON.parse(JSON.stringify(nodes.value)), + edges: JSON.parse(JSON.stringify(edges.value)) + } + + // Remove future history if we're not at the end | 如果不在末尾,删除未来历史 + if (historyIndex.value < history.value.length - 1) { + history.value = history.value.slice(0, historyIndex.value + 1) + } + + // Add new state | 添加新状态 + history.value.push(state) + + // Limit history size | 限制历史大小 + if (history.value.length > MAX_HISTORY) { + history.value.shift() + } else { + historyIndex.value++ + } +} + +/** + * Start batch operation | 开始批量操作 + * Records the starting state for batch operations + */ +export const startBatchOperation = () => { + isBatchOperation = true + batchStartState = { + nodes: JSON.parse(JSON.stringify(nodes.value)), + edges: JSON.parse(JSON.stringify(edges.value)) + } +} + +/** + * End batch operation and save to history | 结束批量操作并保存到历史 + * Compares with start state to decide if save is needed + */ +export const endBatchOperation = () => { + if (!isBatchOperation || !batchStartState) { + isBatchOperation = false + return + } + + // Check if there are significant changes | 检查是否有显著变化 + const hasSignificantChanges = checkSignificantChanges(batchStartState, { + nodes: nodes.value, + edges: edges.value + }) + + if (hasSignificantChanges) { + saveToHistory(true) + } + + isBatchOperation = false + batchStartState = null +} + +/** + * Check if changes are significant enough to save | 检查变化是否足够显著需要保存 + * @param {object} oldState - Previous state | 之前的状态 + * @param {object} newState - New state | 新状态 + * @returns {boolean} - Whether changes should be saved | 是否应该保存变化 + */ +const checkSignificantChanges = (oldState, newState) => { + const oldNodes = oldState.nodes || [] + const newNodes = newState.nodes || [] + + // Check for added or removed nodes | 检查添加或删除的节点 + if (oldNodes.length !== newNodes.length) { + return true + } + + // Check for new nodes (by comparing IDs) | 检查新节点 + const oldNodeIds = new Set(oldNodes.map(n => n.id)) + const newNodeIds = new Set(newNodes.map(n => n.id)) + + // Nodes added | 添加的节点 + for (const id of newNodeIds) { + if (!oldNodeIds.has(id)) { + return true + } + } + + // Nodes removed | 删除的节点 + for (const id of oldNodeIds) { + if (!newNodeIds.has(id)) { + return true + } + } + + // Check position changes for existing nodes | 检查现有节点的位置变化 + for (const newNode of newNodes) { + const oldNode = oldNodes.find(n => n.id === newNode.id) + if (oldNode) { + const dx = Math.abs(newNode.position.x - oldNode.position.x) + const dy = Math.abs(newNode.position.y - oldNode.position.y) + + // If any node moved beyond threshold, save | 如果任何节点移动超过阈值,则保存 + if (dx > POSITION_THRESHOLD || dy > POSITION_THRESHOLD) { + return true + } + } + } + + // Check for edge changes | 检查边的变化 + const oldEdges = oldState.edges || [] + const newEdges = newState.edges || [] + + if (oldEdges.length !== newEdges.length) { + return true + } + + return false +} + +// Add a new node | 添加新节点 +export const addNode = (type, position = { x: 100, y: 100 }, data = {}) => { + const id = getNodeId() + const now = Date.now() + const newNode = { + id, + type, + position, + data: { + ...getDefaultNodeData(type), + ...data, + createdAt: data.createdAt || now, + updatedAt: data.updatedAt || now + } + } + nodes.value = [...nodes.value, newNode] + saveToHistory() // Save after adding node | 添加节点后保存 + return id +} + +/** + * Add multiple nodes in batch | 批量添加多个节点 + * Uses batch operation to group all node additions into one history entry + * @param {Array} nodeSpecs - Array of node specs [{ type, position, data }, ...] + * @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true) + * @returns {Array} - Array of created node IDs | 创建的节点ID数组 + */ +export const addNodes = (nodeSpecs, autoBatch = true) => { + if (!nodeSpecs || nodeSpecs.length === 0) return [] + + // Start batch operation if auto | 如果自动管理则开始批量操作 + if (autoBatch) { + startBatchOperation() + } + + const ids = [] + const now = Date.now() + + nodeSpecs.forEach(spec => { + const { type, position = { x: 100, y: 100 }, data = {} } = spec + const id = getNodeId() + const newNode = { + id, + type, + position, + data: { + ...getDefaultNodeData(type), + ...data, + createdAt: data.createdAt || now, + updatedAt: data.updatedAt || now + } + } + nodes.value = [...nodes.value, newNode] + ids.push(id) + }) + + // End batch operation if auto | 如果自动管理则结束批量操作并保存到历史 + if (autoBatch) { + endBatchOperation() + } + + return ids +} + +// Get default data for node type | 获取节点类型的默认数据 +const getDefaultNodeData = (type) => { + switch (type) { + case 'text': + return { + content: '', + label: '文本输入', + publicProps: {} // 公共属性(可被 @ 引用) + } + case 'imageConfig': { + const imageModel = IMAGE_MODELS.find(m => m.key === DEFAULT_IMAGE_MODEL) || IMAGE_MODELS[0] + return { + prompt: '', + model: DEFAULT_IMAGE_MODEL, + size: imageModel?.defaultParams?.size || '1x1', + quality: imageModel?.defaultParams?.quality || 'standard', + label: '文生图' + } + } + case 'videoConfig': { + const videoModel = VIDEO_MODELS.find(m => m.key === DEFAULT_VIDEO_MODEL) || VIDEO_MODELS[0] + return { + prompt: '', + ratio: videoModel?.defaultParams?.ratio || '16:9', + duration: videoModel?.defaultParams?.duration || 5, + model: DEFAULT_VIDEO_MODEL, + label: '图生视频' + } + } + case 'video': + return { + url: '', + duration: 0, + label: '视频节点' + } + case 'image': + return { + url: '', + label: '图片节点', + publicProps: { name: '图片' } // 公共属性(可被 @ 引用) + } + case 'llmConfig': + return { + systemPrompt: '', + model: DEFAULT_CHAT_MODEL, + outputFormat: 'text', + outputContent: '', + label: 'LLM文本生成', + publicProps: {} // 公共属性(可被 @ 引用) + } + default: + return {} + } +} + +// Update node data | 更新节点数据 +export const updateNode = (id, data) => { + nodes.value = nodes.value.map(node => + node.id === id ? { ...node, data: { ...node.data, ...data } } : node + ) +} + +// Remove node | 删除节点 +export const removeNode = (id) => { + nodes.value = nodes.value.filter(node => node.id !== id) + edges.value = edges.value.filter(edge => edge.source !== id && edge.target !== id) + saveToHistory() // Save after removing node | 删除节点后保存 +} + +// Duplicate node | 复制节点 +export const duplicateNode = (id) => { + const sourceNode = nodes.value.find(node => node.id === id) + if (!sourceNode) return null + + const newId = getNodeId() + + // Calculate max z-index | 计算最大层级 + const maxZIndex = Math.max(0, ...nodes.value.map(n => n.zIndex || 0)) + + const newNode = { + id: newId, + type: sourceNode.type, + position: { + x: sourceNode.position.x + 50, + y: sourceNode.position.y + 50 + }, + data: { ...sourceNode.data }, + zIndex: maxZIndex + 1 + } + nodes.value = [...nodes.value, newNode] + saveToHistory() // Save after duplicating node | 复制节点后保存 + return newId +} + +// Add edge | 添加边 +export const addEdge = (params) => { + const newEdge = { + id: `edge_${params.source}_${params.target}`, + ...params + } + edges.value = [...edges.value, newEdge] + saveToHistory() // Save after adding edge | 添加连线后保存 +} + +/** + * Add multiple edges in batch | 批量添加多条边 + * Uses batch operation to group all edge additions into one history entry + * @param {Array} edgeSpecs - Array of edge specs [{ source, target, sourceHandle, targetHandle, type, data }, ...] + * @param {boolean} autoBatch - Whether to auto-manage batch operation (default: true) + * @returns {Array} - Array of created edge IDs | 创建的边ID数组 + */ +export const addEdges = (edgeSpecs, autoBatch = true) => { + if (!edgeSpecs || edgeSpecs.length === 0) return [] + + // Start batch operation if auto | 如果自动管理则开始批量操作 + if (autoBatch) { + startBatchOperation() + } + + const ids = [] + + edgeSpecs.forEach(params => { + const newEdge = { + id: `edge_${params.source}_${params.target}`, + ...params + } + edges.value = [...edges.value, newEdge] + ids.push(newEdge.id) + }) + + // End batch operation if auto | 如果自动管理则结束批量操作并保存到历史 + if (autoBatch) { + endBatchOperation() + } + + return ids +} + +// Update edge data | 更新边数据 +export const updateEdge = (id, data) => { + edges.value = edges.value.map(edge => + edge.id === id ? { ...edge, data: { ...edge.data, ...data } } : edge + ) + saveToHistory() // Save after updating edge | 更新连线后保存 +} + +// Remove edge | 删除边 +export const removeEdge = (id) => { + edges.value = edges.value.filter(edge => edge.id !== id) + saveToHistory() // Save after removing edge | 删除连线后保存 +} + +// Clear canvas | 清空画布 +export const clearCanvas = () => { + nodes.value = [] + edges.value = [] + nodeId = 0 +} + +// Initialize with sample data | 使用示例数据初始化 +export const initSampleData = () => { + clearCanvas() + + // Add text node | 添加文本节点 + addNode('text', { x: 150, y: 150 }, { + content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。', + label: '文本输入' + }) + + // Add image config node | 添加文生图配置节点 + addNode('imageConfig', { x: 450, y: 150 }, { + prompt: '', + model: 'doubao-seedream-4-5-251128', + ratio: '16:9 | 4张 | 高清', + label: '文生图' + }) + + // Add edge between nodes | 添加节点之间的边 + addEdge({ + source: 'node_0', + target: 'node_1', + sourceHandle: 'right', + targetHandle: 'left' + }) +} + +/** + * Load project data | 加载项目数据 + * @param {string} projectId - Project ID | 项目ID + */ +export const loadProject = (projectId) => { + autoSaveEnabled = false + isRestoring = true + currentProjectId.value = projectId + + const canvasData = getProjectCanvas(projectId) + + if (canvasData) { + // Restore nodes | 恢复节点 + nodes.value = canvasData.nodes || [] + edges.value = canvasData.edges || [] + canvasViewport.value = canvasData.viewport || { x: 100, y: 50, zoom: 0.8 } + + // Update node ID counter | 更新节点ID计数器 + const maxId = nodes.value.reduce((max, node) => { + const match = node.id.match(/node_(\d+)/) + if (match) { + return Math.max(max, parseInt(match[1], 10)) + } + return max + }, -1) + nodeId = maxId + 1 + } else { + // Empty project | 空项目 + clearCanvas() + } + + // Initialize history with current state | 用当前状态初始化历史 + history.value = [{ + nodes: JSON.parse(JSON.stringify(nodes.value)), + edges: JSON.parse(JSON.stringify(edges.value)) + }] + historyIndex.value = 0 + + // Enable auto-save after loading | 加载后启用自动保存 + setTimeout(() => { + autoSaveEnabled = true + isRestoring = false + }, 100) +} + +/** + * Save current project | 保存当前项目 + */ +export const saveProject = () => { + if (!currentProjectId.value) return + updateProjectCanvas(currentProjectId.value, { + nodes: nodes.value, + edges: edges.value, + viewport: canvasViewport.value + }) +} + +/** + * Debounced auto-save | 防抖动自动保存 + */ +const debouncedSave = () => { + if (!autoSaveEnabled || !currentProjectId.value) return + + if (saveTimeout) { + clearTimeout(saveTimeout) + } + + saveTimeout = setTimeout(() => { + saveProject() + }, 500) +} + +/** + * Update viewport and save | 更新视口并保存 + */ +export const updateViewport = (viewport) => { + canvasViewport.value = viewport + debouncedSave() +} + +/** + * Undo last action | 撤销上一步操作 + */ +export const undo = () => { + if (historyIndex.value <= 0) { + window.$message?.info('没有可撤销的操作') + return false + } + + historyIndex.value-- + restoreState(history.value[historyIndex.value]) + return true +} + +/** + * Redo last undone action | 重做上一步撤销的操作 + */ +export const redo = () => { + if (historyIndex.value >= history.value.length - 1) { + window.$message?.info('没有可重做的操作') + return false + } + + historyIndex.value++ + restoreState(history.value[historyIndex.value]) + return true +} + +/** + * Restore state from history | 从历史恢复状态 + */ +const restoreState = (state) => { + isRestoring = true + nodes.value = JSON.parse(JSON.stringify(state.nodes)) + edges.value = JSON.parse(JSON.stringify(state.edges)) + setTimeout(() => { + isRestoring = false + }, 100) +} + +/** + * Check if can undo | 检查是否可以撤销 + */ +export const canUndo = () => historyIndex.value > 0 + +/** + * Check if can redo | 检查是否可以重做 + */ +export const canRedo = () => historyIndex.value < history.value.length - 1 + +/** + * Manually save current state to history | 手动保存当前状态到历史 + * Used for edge deletions and other operations not covered by automatic saves + */ +export const manualSaveHistory = () => { + saveToHistory() +} + +// Watch for changes and auto-save (only save to project, not history) | 监听变化并自动保存(仅保存项目,不保存历史) +watch([nodes, edges], () => { + debouncedSave() +}, { deep: true }) diff --git a/web/canvas-app/src/stores/models.js b/web/canvas-app/src/stores/models.js new file mode 100644 index 0000000..c0beb02 --- /dev/null +++ b/web/canvas-app/src/stores/models.js @@ -0,0 +1,213 @@ +/** + * Model Store | 模型状态管理 + * Built-in models + custom models from local storage | 开源版内置模型 + 本地存储自定义模型 + */ + +import { ref, computed } from 'vue' +import { + IMAGE_MODELS, + VIDEO_MODELS, + CHAT_MODELS, + SEEDREAM_SIZE_OPTIONS, + SEEDREAM_4K_SIZE_OPTIONS, + SEEDREAM_QUALITY_OPTIONS, + SEEDANCE_RESOLUTION_OPTIONS, + VIDEO_RATIO_LIST, + VIDEO_RATIO_OPTIONS, + VIDEO_DURATION_OPTIONS, + DEFAULT_IMAGE_MODEL, + DEFAULT_VIDEO_MODEL, + DEFAULT_CHAT_MODEL, + DEFAULT_IMAGE_SIZE, + DEFAULT_VIDEO_RATIO, + DEFAULT_VIDEO_DURATION +} from '@/config/models' +import { useModelConfig } from '@/hooks/useModelConfig' + +// Loading state (always false for built-in models) | 加载状态 +const loading = ref(false) +const error = ref(null) + +// Get model config hook | 获取模型配置 hook +const getModelConfigHook = () => { + try { + return useModelConfig() + } catch { + return null + } +} + +/** + * Initialize models (no-op for built-in) | 初始化模型 + */ +export const loadAllModels = async () => { + const modelConfig = getModelConfigHook() + if (modelConfig) { + return [...modelConfig.allImageModels.value, ...modelConfig.allVideoModels.value, ...modelConfig.allChatModels.value] + } + return [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS] +} + +/** + * Get model config by name | 根据名称获取模型配置 + */ +export const getModelConfig = (modelKey) => { + const modelConfig = getModelConfigHook() + if (modelConfig) { + return modelConfig.getImageModel(modelKey) || + modelConfig.getVideoModel(modelKey) || + modelConfig.getChatModel(modelKey) + } + const allModels = [...IMAGE_MODELS, ...VIDEO_MODELS, ...CHAT_MODELS] + return allModels.find(m => m.key === modelKey) +} + +/** + * Get size options for image model | 获取图片模型尺寸选项 + * Returns options based on model's sizes array and quality + */ +export const getModelSizeOptions = (modelKey, quality = 'standard') => { + const model = IMAGE_MODELS.find(m => m.key === modelKey) + + // If model has getSizesByQuality function, use it | 如果模型有 getSizesByQuality 函数,使用它 + if (model?.getSizesByQuality) { + return model.getSizesByQuality(quality) + } + + if (!model?.sizes) return SEEDREAM_SIZE_OPTIONS + + // Convert sizes array to dropdown options | 转换 sizes 数组为下拉选项 + const sizeOptions = quality === '4k' ? SEEDREAM_4K_SIZE_OPTIONS : SEEDREAM_SIZE_OPTIONS + return model.sizes.map(size => { + const option = sizeOptions.find(o => o.key === size) + return option || { label: size, key: size } + }) +} + +/** + * Get quality options for image model | 获取图片模型画质选项 + */ +export const getModelQualityOptions = (modelKey) => { + const model = IMAGE_MODELS.find(m => m.key === modelKey) + return model?.qualities || [] +} + +/** + * Get ratio options for video model | 获取视频模型比例选项 + * Returns options based on model's ratios array + */ +export const getModelRatioOptions = (modelKey) => { + const model = VIDEO_MODELS.find(m => m.key === modelKey) + if (!model?.ratios) return VIDEO_RATIO_OPTIONS + + // Convert ratios array to dropdown options | 转换 ratios 数组为下拉选项 + return model.ratios.map(ratio => { + const option = VIDEO_RATIO_LIST.find(o => o.key === ratio) + return option || { label: ratio, key: ratio } + }) +} + +/** + * Get duration options for video model | 获取视频模型时长选项 + * Returns options based on model's durs array + */ +export const getModelDurationOptions = (modelKey) => { + const model = VIDEO_MODELS.find(m => m.key === modelKey) + if (!model?.durs) return VIDEO_DURATION_OPTIONS + + // durs is already in { label, key } format | durs 已经是 { label, key } 格式 + return model.durs +} + +/** + * Get resolution options for video model | 获取视频模型分辨率选项 + * Returns options based on model's resolutions array + */ +export const getModelResolutionOptions = (modelKey) => { + const model = VIDEO_MODELS.find(m => m.key === modelKey) + if (!model?.resolutions) return SEEDANCE_RESOLUTION_OPTIONS + + return model.resolutions.map(res => { + const option = SEEDANCE_RESOLUTION_OPTIONS.find(o => o.key === res) + return option || { label: res, key: res } + }) +} + +// Dropdown options (built-in + custom) | 下拉选项(内置 + 自定义)- 根据渠道过滤 +export const imageModelOptions = computed(() => { + const modelConfig = getModelConfigHook() + return modelConfig ? modelConfig.availableImageModels.value : IMAGE_MODELS +}) + +export const videoModelOptions = computed(() => { + const modelConfig = getModelConfigHook() + return modelConfig ? modelConfig.availableVideoModels.value : VIDEO_MODELS +}) + +export const chatModelOptions = computed(() => { + const modelConfig = getModelConfigHook() + return modelConfig ? modelConfig.availableChatModels.value : CHAT_MODELS +}) + +// All model options (not filtered by provider) | 所有模型选项(不按渠道过滤) +export const allImageModelOptions = computed(() => { + const modelConfig = getModelConfigHook() + return modelConfig ? modelConfig.allAvailableImageModels.value : IMAGE_MODELS +}) + +export const allVideoModelOptions = computed(() => { + const modelConfig = getModelConfigHook() + return modelConfig ? modelConfig.allAvailableVideoModels.value : VIDEO_MODELS +}) + +export const allChatModelOptions = computed(() => { + const modelConfig = getModelConfigHook() + return modelConfig ? modelConfig.allAvailableChatModels.value : CHAT_MODELS +}) + +// Simple select options (for n-select) | 简单选择选项 +export const imageModelSelectOptions = computed(() => + imageModelOptions.value.map(m => ({ label: m.label, value: m.key })) +) + +export const videoModelSelectOptions = computed(() => + videoModelOptions.value.map(m => ({ label: m.label, value: m.key })) +) + +export const chatModelSelectOptions = computed(() => + chatModelOptions.value.map(m => ({ label: m.label, value: m.key })) +) + +// All select options (not filtered by provider) | 所有选择选项(不按渠道过滤) +export const allImageModelSelectOptions = computed(() => + allImageModelOptions.value.map(m => ({ label: m.label, value: m.key })) +) + +export const allVideoModelSelectOptions = computed(() => + allVideoModelOptions.value.map(m => ({ label: m.label, value: m.key })) +) + +export const allChatModelSelectOptions = computed(() => + allChatModelOptions.value.map(m => ({ label: m.label, value: m.key })) +) + +// Export model arrays (reactive with custom models) | 导出模型数组(响应式,包含自定义模型) +export const imageModels = computed(() => imageModelOptions.value) +export const videoModels = computed(() => videoModelOptions.value) +export const chatModels = computed(() => chatModelOptions.value) + +// Export defaults | 导出默认值 +export { + DEFAULT_IMAGE_MODEL, + DEFAULT_VIDEO_MODEL, + DEFAULT_CHAT_MODEL, + DEFAULT_IMAGE_SIZE, + DEFAULT_VIDEO_RATIO, + DEFAULT_VIDEO_DURATION +} + +// Export options | 导出选项 +export { SEEDREAM_SIZE_OPTIONS, SEEDREAM_4K_SIZE_OPTIONS, SEEDREAM_QUALITY_OPTIONS, SEEDANCE_RESOLUTION_OPTIONS, VIDEO_RATIO_OPTIONS, VIDEO_DURATION_OPTIONS } + +// Export state | 导出状态 +export { loading, error } diff --git a/web/canvas-app/src/stores/pinia/index.js b/web/canvas-app/src/stores/pinia/index.js new file mode 100644 index 0000000..bfdf38e --- /dev/null +++ b/web/canvas-app/src/stores/pinia/index.js @@ -0,0 +1,6 @@ +/** + * Pinia Stores | Pinia 状态管理 + * 统一导出所有 Pinia stores + */ + +export { useModelStore } from './models' diff --git a/web/canvas-app/src/stores/pinia/models.js b/web/canvas-app/src/stores/pinia/models.js new file mode 100644 index 0000000..33fbbf1 --- /dev/null +++ b/web/canvas-app/src/stores/pinia/models.js @@ -0,0 +1,613 @@ +/** + * Pinia Store: Model Config | 模型配置 Store + * 管理模型配置、渠道切换和模型选择 + */ + +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' +import { + CHAT_MODELS, + IMAGE_MODELS, + VIDEO_MODELS, + DEFAULT_CHAT_MODEL, + DEFAULT_IMAGE_MODEL, + DEFAULT_VIDEO_MODEL +} from '@/config/models' +import { PROVIDERS, getProviderList, getDefaultProvider, getProviderConfig, getDefaultBaseUrl } from '@/config/providers' + +// 存储键名 +const STORAGE_KEYS = { + PROVIDER: 'api-provider', + CUSTOM_CHAT_MODELS: 'custom-chat-models', + CUSTOM_IMAGE_MODELS: 'custom-image-models', + CUSTOM_VIDEO_MODELS: 'custom-video-models', + SELECTED_CHAT_MODEL: 'selected-chat-model', + SELECTED_IMAGE_MODEL: 'selected-image-model', + SELECTED_VIDEO_MODEL: 'selected-video-model', + CUSTOM_CHAT_MODELS_BY_PROVIDER: 'custom-chat-models-by-provider', + CUSTOM_IMAGE_MODELS_BY_PROVIDER: 'custom-image-models-by-provider', + CUSTOM_VIDEO_MODELS_BY_PROVIDER: 'custom-video-models-by-provider', + API_KEYS_BY_PROVIDER: 'api-keys-by-provider', + BASE_URLS_BY_PROVIDER: 'base-urls-by-provider' +} + +/** + * Get stored value from localStorage + */ +const getStored = (key, defaultValue = '') => { + try { + return localStorage.getItem(key) || defaultValue + } catch { + return defaultValue + } +} + +/** + * Set stored value to localStorage + */ +const setStored = (key, value) => { + try { + if (value) { + localStorage.setItem(key, value) + } else { + localStorage.removeItem(key) + } + } catch { + // ignore + } +} + +/** + * Get stored JSON value from localStorage + */ +const getStoredJson = (key, defaultValue = []) => { + try { + const stored = localStorage.getItem(key) + return stored ? JSON.parse(stored) : defaultValue + } catch { + return defaultValue + } +} + +/** + * Set stored JSON value to localStorage + */ +const setStoredJson = (key, value) => { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // ignore + } +} + +/** + * 检查模型是否支持指定渠道 + */ +const isModelSupported = (model, provider) => { + if (!model.provider) { + return true + } + return model.provider.includes(provider) +} + +export const useModelStore = defineStore('model', () => { + // ============ Provider 状态 | Provider State ============ + + // 当前选中的渠道 + const currentProvider = ref(getStored(STORAGE_KEYS.PROVIDER) || getDefaultProvider()) + + // 渠道列表 + const providerList = computed(() => getProviderList()) + + // 当前渠道配置 + const providerConfig = computed(() => getProviderConfig(currentProvider.value)) + + // 当前渠道标签 + const providerLabel = computed(() => providerConfig.value.label || currentProvider.value) + + // 设置当前渠道 + const setProvider = (provider) => { + if (PROVIDERS[provider]) { + currentProvider.value = provider + setStored(STORAGE_KEYS.PROVIDER, provider) + } + } + + // 清除渠道配置 + const clearProvider = () => { + currentProvider.value = getDefaultProvider() + removeStored(STORAGE_KEYS.PROVIDER) + } + + // 适配请求参数 + const adaptRequest = (type, params) => { + const config = providerConfig.value + if (config.requestAdapter && config.requestAdapter[type]) { + return config.requestAdapter[type](params) + } + return params + } + + // 适配响应数据 + const adaptResponse = (type, response) => { + const config = providerConfig.value + if (config.responseAdapter && config.responseAdapter[type]) { + return config.responseAdapter[type](response) + } + return response + } + + // ============ Custom Models 状态 | Custom Models State ============ + + // 全局自定义模型(不区分渠道) + const customChatModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, [])) + const customImageModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, [])) + const customVideoModels = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, [])) + + // 按渠道存储的自定义模型 | 结构: { 'skg': [{key, label}] } + const customChatModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, {})) + const customImageModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, {})) + const customVideoModelsByProvider = ref(getStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, {})) + + // 选中的模型 + const selectedChatModel = ref(getStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, DEFAULT_CHAT_MODEL)) + const selectedImageModel = ref(getStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, DEFAULT_IMAGE_MODEL)) + const selectedVideoModel = ref(getStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, DEFAULT_VIDEO_MODEL)) + + // 按渠道存储的 API 配置 + const apiKeysByProvider = ref(getStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, {})) + const baseUrlsByProvider = ref(getStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, {})) + + // 内部模式由服务端会话鉴权,不在浏览器暴露上游模型密钥。 + const currentApiKey = computed(() => 'internal-session') + const currentBaseUrl = computed(() => baseUrlsByProvider.value[currentProvider.value] || getDefaultBaseUrl(currentProvider.value)) + + // 设置指定渠道凭据(兼容旧本地状态) + const setApiKeyByProvider = (provider, apiKey) => { + apiKeysByProvider.value[provider] = apiKey + } + + // 设置指定渠道的 Base URL + const setBaseUrlByProvider = (provider, baseUrl) => { + baseUrlsByProvider.value[provider] = baseUrl + } + + // 清除指定渠道的 API 配置 + const clearApiConfigByProvider = (provider) => { + delete apiKeysByProvider.value[provider] + delete baseUrlsByProvider.value[provider] + } + + // ============ Computed: All Models (built-in + custom + by provider) ============ + + const allChatModels = computed(() => [ + ...CHAT_MODELS.map(m => ({ ...m, isCustom: false })), + ...customChatModels.value.map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true + })), + // 添加当前渠道的自定义模型 + ...(customChatModelsByProvider.value[currentProvider.value] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + provider: [currentProvider.value] + })) + ]) + + const allImageModels = computed(() => [ + ...IMAGE_MODELS.map(m => ({ ...m, isCustom: false })), + ...customImageModels.value.map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + sizes: [], + defaultParams: { quality: 'standard', style: 'vivid' } + })), + // 添加当前渠道的自定义模型 + ...(customImageModelsByProvider.value[currentProvider.value] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + sizes: [], + defaultParams: { quality: 'standard', style: 'vivid' }, + provider: [currentProvider.value] + })) + ]) + + const allVideoModels = computed(() => [ + ...VIDEO_MODELS.map(m => ({ ...m, isCustom: false })), + ...customVideoModels.value.map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + ratios: ['16x9', '9:16', '1:1'], + durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }], + defaultParams: { ratio: '16:9', duration: 5 } + })), + // 添加当前渠道的自定义模型 + ...(customVideoModelsByProvider.value[currentProvider.value] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + ratios: ['16x9', '9:16', '1:1'], + durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }], + defaultParams: { ratio: '16:9', duration: 5 }, + provider: [currentProvider.value] + })) + ]) + + // ============ Computed: Available Models (filtered by provider) ============ + + // 按渠道过滤的可用模型 + const availableChatModels = computed(() => + allChatModels.value.filter(m => isModelSupported(m, currentProvider.value)) + ) + + const availableImageModels = computed(() => + allImageModels.value.filter(m => isModelSupported(m, currentProvider.value)) + ) + + const availableVideoModels = computed(() => + allVideoModels.value.filter(m => isModelSupported(m, currentProvider.value)) + ) + + // ============ Computed: Model Options for UI (all models, not filtered by provider) ============ + + // 返回适合 n-dropdown 使用的选项格式(全部模型,不按渠道过滤) + const allImageModelOptions = computed(() => + allImageModels.value.map(m => ({ + label: m.label, + key: m.key + })) + ) + + const allVideoModelOptions = computed(() => + allVideoModels.value.map(m => ({ + label: m.label, + key: m.key + })) + ) + + const allChatModelOptions = computed(() => + allChatModels.value.map(m => ({ + label: m.label, + key: m.key + })) + ) + + // ============ Computed: Model Options for UI (filtered by provider - deprecated, use all* instead) ============ + + // 返回适合 n-dropdown 使用的选项格式 + const imageModelOptions = computed(() => + availableImageModels.value.map(m => ({ + label: m.label, + key: m.key + })) + ) + + const videoModelOptions = computed(() => + availableVideoModels.value.map(m => ({ + label: m.label, + key: m.key + })) + ) + + const chatModelOptions = computed(() => + availableChatModels.value.map(m => ({ + label: m.label, + key: m.key + })) + ) + + // ============ Methods: Add/Remove Custom Models ============ + + const addCustomChatModel = (modelKey, label = '') => { + if (!modelKey || customChatModels.value.some(m => m.key === modelKey)) return false + customChatModels.value.push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomImageModel = (modelKey, label = '') => { + if (!modelKey || customImageModels.value.some(m => m.key === modelKey)) return false + customImageModels.value.push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomVideoModel = (modelKey, label = '') => { + if (!modelKey || customVideoModels.value.some(m => m.key === modelKey)) return false + customVideoModels.value.push({ key: modelKey, label: label || modelKey }) + return true + } + + const removeCustomChatModel = (modelKey) => { + const idx = customChatModels.value.findIndex(m => m.key === modelKey) + if (idx > -1) { + customChatModels.value.splice(idx, 1) + if (selectedChatModel.value === modelKey) { + selectedChatModel.value = DEFAULT_CHAT_MODEL + } + return true + } + return false + } + + const removeCustomImageModel = (modelKey) => { + const idx = customImageModels.value.findIndex(m => m.key === modelKey) + if (idx > -1) { + customImageModels.value.splice(idx, 1) + if (selectedImageModel.value === modelKey) { + selectedImageModel.value = DEFAULT_IMAGE_MODEL + } + return true + } + return false + } + + const removeCustomVideoModel = (modelKey) => { + const idx = customVideoModels.value.findIndex(m => m.key === modelKey) + if (idx > -1) { + customVideoModels.value.splice(idx, 1) + if (selectedVideoModel.value === modelKey) { + selectedVideoModel.value = DEFAULT_VIDEO_MODEL + } + return true + } + return false + } + + // ============ Methods: Get Model Config ============ + + const getChatModel = (key) => allChatModels.value.find(m => m.key === key) + const getImageModel = (key) => allImageModels.value.find(m => m.key === key) + const getVideoModel = (key) => allVideoModels.value.find(m => m.key === key) + + // ============ Methods: Get API Endpoints ============ + + // 获取图片端点 + const getImageEndpoint = () => { + const endpoint = providerConfig.value.endpoints?.image || '/images/generations' + return `${currentBaseUrl.value}${endpoint}` + } + + // 获取视频生成端点 + const getVideoEndpoint = () => { + const endpoint = providerConfig.value.endpoints?.video || '/videos' + return `${currentBaseUrl.value}${endpoint}` + } + + // 获取视频任务查询端点 + const getVideoTaskEndpoint = () => { + const config = providerConfig.value + // 优先使用 videoQuery 端点,支持 {taskId} 占位符替换 + let endpoint = config.endpoints?.videoQuery || config.endpoints?.video || '/videos' + return `${currentBaseUrl.value}${endpoint}` + } + + // 获取聊天端点(支持参考图片) + const getChatEndpoint = () => { + const endpoint = providerConfig.value?.endpoints?.chat || '/chat/completions' + return `${currentBaseUrl.value}${endpoint}` + } + + // ============ Methods: Get Models By Provider ============ + + const getModelsByProvider = (provider) => { + const chat = [ + ...CHAT_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), + ...(customChatModelsByProvider.value[provider] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + provider: [provider] + })) + ] + const image = [ + ...IMAGE_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), + ...(customImageModelsByProvider.value[provider] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + sizes: [], + defaultParams: { quality: 'standard', style: 'vivid' }, + provider: [provider] + })) + ] + const video = [ + ...VIDEO_MODELS.filter(m => isModelSupported(m, provider)).map(m => ({ ...m, isCustom: false })), + ...(customVideoModelsByProvider.value[provider] || []).map(m => ({ + label: m.label || m.key, + key: m.key, + isCustom: true, + ratios: ['16x9', '9:16', '1:1'], + durs: [{ label: '5 秒', key: 5 }, { label: '10 秒', key: 10 }], + defaultParams: { ratio: '16:9', duration: 5 }, + provider: [provider] + })) + ] + return { chat, image, video } + } + + // ============ Methods: Add/Remove Custom Models By Provider ============ + + const addCustomChatModelByProvider = (modelKey, provider, label = '') => { + if (!modelKey) return false + if (!customChatModelsByProvider.value[provider]) { + customChatModelsByProvider.value[provider] = [] + } + if (customChatModelsByProvider.value[provider].some(m => m.key === modelKey)) return false + customChatModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomImageModelByProvider = (modelKey, provider, label = '') => { + if (!modelKey) return false + if (!customImageModelsByProvider.value[provider]) { + customImageModelsByProvider.value[provider] = [] + } + if (customImageModelsByProvider.value[provider].some(m => m.key === modelKey)) return false + customImageModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) + return true + } + + const addCustomVideoModelByProvider = (modelKey, provider, label = '') => { + if (!modelKey) return false + if (!customVideoModelsByProvider.value[provider]) { + customVideoModelsByProvider.value[provider] = [] + } + if (customVideoModelsByProvider.value[provider].some(m => m.key === modelKey)) return false + customVideoModelsByProvider.value[provider].push({ key: modelKey, label: label || modelKey }) + return true + } + + const removeCustomChatModelByProvider = (modelKey, provider) => { + if (!customChatModelsByProvider.value[provider]) return false + const idx = customChatModelsByProvider.value[provider].findIndex(m => m.key === modelKey) + if (idx > -1) { + customChatModelsByProvider.value[provider].splice(idx, 1) + return true + } + return false + } + + const removeCustomImageModelByProvider = (modelKey, provider) => { + if (!customImageModelsByProvider.value[provider]) return false + const idx = customImageModelsByProvider.value[provider].findIndex(m => m.key === modelKey) + if (idx > -1) { + customImageModelsByProvider.value[provider].splice(idx, 1) + return true + } + return false + } + + const removeCustomVideoModelByProvider = (modelKey, provider) => { + if (!customVideoModelsByProvider.value[provider]) return false + const idx = customVideoModelsByProvider.value[provider].findIndex(m => m.key === modelKey) + if (idx > -1) { + customVideoModelsByProvider.value[provider].splice(idx, 1) + return true + } + return false + } + + // 清除所有自定义模型 + const clearCustomModels = () => { + customChatModels.value = [] + customImageModels.value = [] + customVideoModels.value = [] + selectedChatModel.value = DEFAULT_CHAT_MODEL + selectedImageModel.value = DEFAULT_IMAGE_MODEL + selectedVideoModel.value = DEFAULT_VIDEO_MODEL + } + + // ============ Watch & Persist ============ + + // 监听并持久化自定义模型 + watch(customChatModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS, val), { deep: true }) + watch(customImageModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS, val), { deep: true }) + watch(customVideoModels, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS, val), { deep: true }) + + // 监听并持久化按渠道的自定义模型 + watch(customChatModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_CHAT_MODELS_BY_PROVIDER, val), { deep: true }) + watch(customImageModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_IMAGE_MODELS_BY_PROVIDER, val), { deep: true }) + watch(customVideoModelsByProvider, (val) => setStoredJson(STORAGE_KEYS.CUSTOM_VIDEO_MODELS_BY_PROVIDER, val), { deep: true }) + + // 监听并持久化选中的模型 + watch(selectedChatModel, (val) => setStored(STORAGE_KEYS.SELECTED_CHAT_MODEL, val)) + watch(selectedImageModel, (val) => setStored(STORAGE_KEYS.SELECTED_IMAGE_MODEL, val)) + watch(selectedVideoModel, (val) => setStored(STORAGE_KEYS.SELECTED_VIDEO_MODEL, val)) + + // 监听并持久化 API 配置 + watch(apiKeysByProvider, (val) => setStoredJson(STORAGE_KEYS.API_KEYS_BY_PROVIDER, val), { deep: true }) + watch(baseUrlsByProvider, (val) => setStoredJson(STORAGE_KEYS.BASE_URLS_BY_PROVIDER, val), { deep: true }) + + return { + // Provider + currentProvider, + providerList, + providerConfig, + providerLabel, + setProvider, + clearProvider, + adaptRequest, + adaptResponse, + + // All models (built-in + custom) + allChatModels, + allImageModels, + allVideoModels, + + // Available models filtered by provider + availableChatModels, + availableImageModels, + availableVideoModels, + + // Model options for UI (dropdown format) + imageModelOptions, + videoModelOptions, + chatModelOptions, + + // All model options (not filtered by provider) + allImageModelOptions, + allVideoModelOptions, + allChatModelOptions, + + // Selected models + selectedChatModel, + selectedImageModel, + selectedVideoModel, + + // Custom models + customChatModels, + customImageModels, + customVideoModels, + + // Custom models by provider + customChatModelsByProvider, + customImageModelsByProvider, + customVideoModelsByProvider, + + // Add/Remove methods + addCustomChatModel, + addCustomImageModel, + addCustomVideoModel, + removeCustomChatModel, + removeCustomImageModel, + removeCustomVideoModel, + + // Add/Remove by provider methods + addCustomChatModelByProvider, + addCustomImageModelByProvider, + addCustomVideoModelByProvider, + removeCustomChatModelByProvider, + removeCustomImageModelByProvider, + removeCustomVideoModelByProvider, + + // Get model + getChatModel, + getImageModel, + getVideoModel, + + // Get API endpoints + getImageEndpoint, + getVideoEndpoint, + getVideoTaskEndpoint, + getChatEndpoint, + + // Get models by provider + getModelsByProvider, + + // Clear all custom models + clearCustomModels, + + // API Config by provider + currentApiKey, + currentBaseUrl, + apiKeysByProvider, + baseUrlsByProvider, + setApiKeyByProvider, + setBaseUrlByProvider, + clearApiConfigByProvider + } +}) diff --git a/web/canvas-app/src/stores/projects.js b/web/canvas-app/src/stores/projects.js new file mode 100644 index 0000000..b285370 --- /dev/null +++ b/web/canvas-app/src/stores/projects.js @@ -0,0 +1,379 @@ +/** + * Projects store | 项目状态管理 + * Manages projects with localStorage persistence + */ +import { ref, computed, watch } from 'vue' + +// Storage key | 存储键 +const STORAGE_KEY = 'ai-canvas-projects' + +// Generate unique ID | 生成唯一ID +const generateId = () => `project_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` + +// Projects list | 项目列表 +export const projects = ref([]) + +// Current project ID | 当前项目ID +export const currentProjectId = ref(null) + +// Current project | 当前项目 +export const currentProject = computed(() => { + return projects.value.find(p => p.id === currentProjectId.value) || null +}) + +/** + * Load projects from localStorage | 从 localStorage 加载项目 + */ +export const loadProjects = () => { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + const parsed = JSON.parse(stored) + // Convert date strings back to Date objects | 将日期字符串转换回 Date 对象 + projects.value = parsed.map(p => ({ + ...p, + createdAt: new Date(p.createdAt), + updatedAt: new Date(p.updatedAt) + })) + } + } catch (err) { + console.error('Failed to load projects:', err) + projects.value = [] + } +} + +/** + * Clean node data for storage | 清理节点数据用于存储 + * Removes base64 data URLs to reduce storage size | 移除 base64 数据减小存储大小 + */ +const cleanNodeForStorage = (node) => { + if (!node.data) return node + + const cleanedData = { ...node.data } + + // Remove base64 data | 移除 base64 数据 + if (cleanedData.base64) { + delete cleanedData.base64 + } + + // If url is a base64 data URL, keep it only if it's from external source | 如果 url 是 base64,只有外部来源才保留 + if (cleanedData.url?.startsWith?.('data:')) { + // For uploaded images, we can't persist them in localStorage | 上传的图片无法持久化到 localStorage + delete cleanedData.url + } + + // Remove mask data | 移除蒙版数据 + if (cleanedData.maskData) { + delete cleanedData.maskData + } + + return { ...node, data: cleanedData } +} + +/** + * Clean project for storage | 清理项目用于存储 + */ +const cleanProjectForStorage = (project) => { + return { + ...project, + canvasData: project.canvasData ? { + ...project.canvasData, + nodes: project.canvasData.nodes?.map(cleanNodeForStorage) || [] + } : project.canvasData, + // Remove base64 thumbnails | 移除 base64 缩略图 + thumbnail: project.thumbnail?.startsWith?.('data:') ? '' : project.thumbnail + } +} + +/** + * Save projects to localStorage | 保存项目到 localStorage + * Handles QuotaExceededError by compressing data | 通过压缩数据处理配额超限错误 + */ +export const saveProjects = () => { + // Always clean data before saving | 保存前始终清理数据 + const cleanedProjects = projects.value.map(cleanProjectForStorage) + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cleanedProjects)) + } catch (err) { + if (err.name === 'QuotaExceededError') { + console.warn('localStorage quota exceeded, attempting aggressive cleanup...') + + // Remove thumbnails and limit old projects | 移除缩略图并限制旧项目 + const minimalProjects = cleanedProjects.map((project, index) => ({ + ...project, + thumbnail: '', // Remove all thumbnails | 移除所有缩略图 + // Keep only essential canvas data for older projects | 旧项目只保留基本画布数据 + canvasData: index > 10 ? { nodes: [], edges: [], viewport: project.canvasData?.viewport } : project.canvasData + })) + + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(minimalProjects)) + console.log('Saved with aggressive cleanup') + window.$message?.warning('存储空间不足,已自动清理部分数据') + } catch (retryErr) { + console.error('Still failed after aggressive cleanup:', retryErr) + // Last resort: only keep first 5 projects | 最后手段:只保留前5个项目 + try { + const essentialProjects = minimalProjects.slice(0, 5) + localStorage.setItem(STORAGE_KEY, JSON.stringify(essentialProjects)) + projects.value = projects.value.slice(0, 5) + window.$message?.warning('存储空间严重不足,已保留最近 5 个项目') + } catch (finalErr) { + console.error('Cannot save even minimal data:', finalErr) + window.$message?.error('存储失败,请清理浏览器存储空间') + } + } + } else { + console.error('Failed to save projects:', err) + } + } +} + +/** + * Create a new project | 创建新项目 + * @param {string} name - Project name | 项目名称 + * @returns {string} - New project ID | 新项目ID + */ +export const createProject = (name = '未命名项目') => { + const id = generateId() + const now = new Date() + + const newProject = { + id, + name, + thumbnail: '', + createdAt: now, + updatedAt: now, + // Canvas data | 画布数据 + canvasData: { + nodes: [], + edges: [], + viewport: { x: 100, y: 50, zoom: 0.8 } + } + } + + projects.value = [newProject, ...projects.value] + saveProjects() + + return id +} + +/** + * Update project | 更新项目 + * @param {string} id - Project ID | 项目ID + * @param {object} data - Update data | 更新数据 + */ +export const updateProject = (id, data) => { + const index = projects.value.findIndex(p => p.id === id) + if (index === -1) return false + + projects.value[index] = { + ...projects.value[index], + ...data, + updatedAt: new Date() + } + + // Move to top of list | 移动到列表顶部 + const [updated] = projects.value.splice(index, 1) + projects.value = [updated, ...projects.value] + + saveProjects() + return true +} + +/** + * Update project canvas data | 更新项目画布数据 + * @param {string} id - Project ID | 项目ID + * @param {object} canvasData - Canvas data (nodes, edges, viewport) | 画布数据 + */ +export const updateProjectCanvas = (id, canvasData) => { + const project = projects.value.find(p => p.id === id) + if (!project) return false + + project.canvasData = { + ...project.canvasData, + ...canvasData + } + project.updatedAt = new Date() + + // Auto-update thumbnail from last edited image/video node | 自动从最后编辑的图片/视频节点更新缩略图 + if (canvasData.nodes) { + const mediaNodes = canvasData.nodes + .filter(node => (node.type === 'image' || node.type === 'video') && node.data?.url) + .sort((a, b) => { + // Sort by last updated time | 按最后更新时间排序 + const aTime = a.data?.updatedAt || a.data?.createdAt || 0 + const bTime = b.data?.updatedAt || b.data?.createdAt || 0 + return bTime - aTime + }) + if (mediaNodes.length > 0) { + const latestNode = mediaNodes[0] + // Use thumbnail for video nodes, url for image nodes | 视频节点使用缩略图,图片节点使用 URL + if (latestNode.type === 'video') { + project.thumbnail = latestNode.data.thumbnail || latestNode.data.url + } else { + project.thumbnail = latestNode.data.url + } + } + } + + saveProjects() + return true +} + +/** + * Get project canvas data | 获取项目画布数据 + * @param {string} id - Project ID | 项目ID + * @returns {object|null} - Canvas data or null | 画布数据或空 + */ +export const getProjectCanvas = (id) => { + const project = projects.value.find(p => p.id === id) + return project?.canvasData || null +} + +/** + * Delete project | 删除项目 + * @param {string} id - Project ID | 项目ID + */ +export const deleteProject = (id) => { + projects.value = projects.value.filter(p => p.id !== id) + saveProjects() +} + +/** + * Duplicate project | 复制项目 + * @param {string} id - Source project ID | 源项目ID + * @returns {string|null} - New project ID or null | 新项目ID或空 + */ +export const duplicateProject = (id) => { + const source = projects.value.find(p => p.id === id) + if (!source) return null + + const newId = generateId() + const now = new Date() + + const newProject = { + ...JSON.parse(JSON.stringify(source)), // Deep clone | 深拷贝 + id: newId, + name: `${source.name} (副本)`, + createdAt: now, + updatedAt: now + } + + projects.value = [newProject, ...projects.value] + saveProjects() + + return newId +} + +/** + * Rename project | 重命名项目 + * @param {string} id - Project ID | 项目ID + * @param {string} name - New name | 新名称 + */ +export const renameProject = (id, name) => { + return updateProject(id, { name }) +} + +/** + * Update project thumbnail | 更新项目缩略图 + * @param {string} id - Project ID | 项目ID + * @param {string} thumbnail - Thumbnail URL (base64 or URL) | 缩略图URL + */ +export const updateProjectThumbnail = (id, thumbnail) => { + return updateProject(id, { thumbnail }) +} + +/** + * Get sorted projects | 获取排序后的项目列表 + * @param {string} sortBy - Sort field (updatedAt, createdAt, name) | 排序字段 + * @param {string} order - Sort order (asc, desc) | 排序顺序 + */ +export const getSortedProjects = (sortBy = 'updatedAt', order = 'desc') => { + return computed(() => { + const sorted = [...projects.value] + sorted.sort((a, b) => { + let valueA = a[sortBy] + let valueB = b[sortBy] + + if (valueA instanceof Date) { + valueA = valueA.getTime() + valueB = valueB.getTime() + } + + if (typeof valueA === 'string') { + valueA = valueA.toLowerCase() + valueB = valueB.toLowerCase() + } + + if (order === 'asc') { + return valueA > valueB ? 1 : -1 + } else { + return valueA < valueB ? 1 : -1 + } + }) + return sorted + }) +} + +/** + * Initialize projects store | 初始化项目存储 + */ +export const initProjectsStore = () => { + loadProjects() + + // Create sample project if empty | 如果为空则创建示例项目 + if (projects.value.length === 0) { + const id = createProject('示例项目') + const project = projects.value.find(p => p.id === id) + if (project) { + project.canvasData = { + nodes: [ + { + id: 'node_0', + type: 'text', + position: { x: 150, y: 150 }, + data: { + content: '一只金毛寻回犬在草地上奔跑,摇着尾巴,脸上带着快乐的表情。它的毛发在阳光下闪耀,眼神充满了对自由的渴望,全身散发着阳光、友善的气息。', + label: '文本输入' + } + }, + { + id: 'node_1', + type: 'imageConfig', + position: { x: 500, y: 150 }, + data: { + prompt: '', + model: 'doubao-seedream-4-5-251128', + size: '512x512', + label: '文生图' + } + } + ], + edges: [ + { + id: 'edge_node_0_node_1', + source: 'node_0', + target: 'node_1', + sourceHandle: 'right', + targetHandle: 'left' + } + ], + viewport: { x: 100, y: 50, zoom: 0.8 } + } + saveProjects() + } + } +} + +// Export for debugging | 导出用于调试 +if (typeof window !== 'undefined') { + window.__aiCanvasProjects = { + projects, + loadProjects, + saveProjects, + createProject, + deleteProject + } +} diff --git a/web/canvas-app/src/stores/theme.js b/web/canvas-app/src/stores/theme.js new file mode 100644 index 0000000..0cdc9a9 --- /dev/null +++ b/web/canvas-app/src/stores/theme.js @@ -0,0 +1,25 @@ +/** + * Theme store | 主题状态管理 + * Handles dark/light mode switching + */ +import { ref, watch } from 'vue' + +// Get initial theme from localStorage or system preference | 从本地存储或系统偏好获取初始主题 +const getInitialTheme = () => { + const stored = localStorage.getItem('theme') + if (stored) return stored === 'dark' + return window.matchMedia('(prefers-color-scheme: dark)').matches +} + +export const isDark = ref(getInitialTheme()) + +// Watch and apply theme changes | 监听并应用主题变化 +watch(isDark, (value) => { + document.documentElement.classList.toggle('dark', value) + localStorage.setItem('theme', value ? 'dark' : 'light') +}, { immediate: true }) + +// Toggle theme | 切换主题 +export const toggleTheme = () => { + isDark.value = !isDark.value +} diff --git a/web/canvas-app/src/style.css b/web/canvas-app/src/style.css new file mode 100644 index 0000000..b54ea7f --- /dev/null +++ b/web/canvas-app/src/style.css @@ -0,0 +1,89 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base styles | 基础样式 */ +:root { + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body, #app { + width: 100%; + height: 100%; + overflow: hidden; +} + +/* Light mode variables | 浅色模式变量 */ +:root { + --bg-primary: #f3f6f8; + --bg-secondary: rgba(255, 255, 255, 0.82); + --bg-tertiary: rgba(229, 236, 240, 0.82); + --text-primary: #111827; + --text-secondary: #667085; + --text-tertiary: #98a2b3; + --border-color: rgba(17, 24, 39, 0.1); + --accent-color: #07a5a5; + --accent-hover: #078b8b; +} + +/* Dark mode variables | 深色模式变量 */ +.dark { + --bg-primary: #0b1117; + --bg-secondary: rgba(20, 28, 37, 0.86); + --bg-tertiary: rgba(42, 54, 66, 0.86); + --text-primary: #f7fafc; + --text-secondary: #a6b0bb; + --text-tertiary: #77828e; + --border-color: rgba(255, 255, 255, 0.12); + --accent-color: #2dd4bf; + --accent-hover: #14b8a6; +} + +body { + background-color: var(--bg-primary); + color: var(--text-primary); + transition: background-color 0.3s, color 0.3s; +} + +header, +.glass-panel { + backdrop-filter: blur(22px); + -webkit-backdrop-filter: blur(22px); +} + +/* Vue Flow styles override | Vue Flow 样式覆盖 */ +.vue-flow { + background-color: var(--bg-primary); +} + +.vue-flow__node { + border-radius: 8px; + /* box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); */ +} +.vue-flow__node.selected { + /* border: 1px solid green; */ +} + +.vue-flow__edge-path { + stroke: var(--border-color); + stroke-width: 2; +} + +.vue-flow__handle { + width: 10px; + height: 10px; + background-color: var(--accent-color) !important; + border: 2px solid var(--bg-secondary); +} diff --git a/web/canvas-app/src/utils/constants.js b/web/canvas-app/src/utils/constants.js new file mode 100644 index 0000000..afa5c93 --- /dev/null +++ b/web/canvas-app/src/utils/constants.js @@ -0,0 +1,59 @@ +/** + * Constants | 常量配置 + */ + +// API Base URL | API 基础 URL +export const DEFAULT_API_BASE_URL = '/api' + +// API Endpoints | API 端点 +export const API_ENDPOINTS = { + // Model | 模型 + MODEL_PAGE: '/model/page', + MODEL_FULL_NAME: '/model/fullName', + MODEL_TYPES: '/model/types', + + // Image | 图片 + IMAGE_GENERATIONS: '/images/generations', + + // Video | 视频 + VIDEO_GENERATIONS: '/videos', + VIDEO_TASK: '/videos', + + // Chat | 对话 + CHAT_COMPLETIONS: '/chat/completions' +} + +// Error Codes | 错误码 +export const ERROR_CODES = { + INVALID_API_KEY: 'INVALID_API_KEY', + RATE_LIMIT: 'RATE_LIMIT', + NETWORK_ERROR: 'NETWORK_ERROR', + TIMEOUT: 'TIMEOUT', + UNKNOWN: 'UNKNOWN' +} + +// Video Poll Config | 视频轮询配置 +export const VIDEO_POLL_CONFIG = { + MAX_ATTEMPTS: 120, + POLL_INTERVAL: 5000 +} + +// Default Chat Config | 默认问答配置 +export const DEFAULT_CHAT_CONFIG = { + supportImage: false, + supportFile: false, + supportWeb: false, + supportDeepThink: false +} + +// Local Storage Keys | 本地存储键 +export const STORAGE_KEYS = { + API_KEY: 'apiKey', + BASE_URL: 'apiBaseUrl', + CUSTOM_CHAT_MODELS: 'customChatModels', + CUSTOM_IMAGE_MODELS: 'customImageModels', + CUSTOM_VIDEO_MODELS: 'customVideoModels', + SELECTED_CHAT_MODEL: 'selectedChatModel', + SELECTED_IMAGE_MODEL: 'selectedImageModel', + SELECTED_VIDEO_MODEL: 'selectedVideoModel' +} diff --git a/web/canvas-app/src/utils/index.js b/web/canvas-app/src/utils/index.js new file mode 100644 index 0000000..04ff5cb --- /dev/null +++ b/web/canvas-app/src/utils/index.js @@ -0,0 +1,9 @@ +/** + * Utils Index | 工具函数索引 + */ + +export * from './constants' +export * from './schema' +import request, { setBaseUrl, getBaseUrl } from './request' + +export { request, setBaseUrl, getBaseUrl } diff --git a/web/canvas-app/src/utils/request.js b/web/canvas-app/src/utils/request.js new file mode 100644 index 0000000..000d9e4 --- /dev/null +++ b/web/canvas-app/src/utils/request.js @@ -0,0 +1,88 @@ +/** + * HTTP Request Utility | HTTP 请求工具 + * Axios-based request with interceptors + */ + +import axios from 'axios' + +// Base URL from environment or default +// Create axios instance | 创建 axios 实例 +const instance = axios.create({ + baseURL: "/api", + timeout: 30000000 +}) + +// Request interceptor | 请求拦截器 +instance.interceptors.request.use( + (config) => { + return config + }, + (error) => { + console.error('Request error:', error) + return Promise.reject(error) + } +) + +// Response interceptor | 响应拦截器 +instance.interceptors.response.use( + (res) => { + const { data, code, message } = res.data || {} + + // Handle stream response | 处理流响应 + if (res.config.responseType === 'stream') { + return res.data + } + + // Handle blob response | 处理 blob 响应 + if (res.data instanceof Blob) { + return res.data + } + + // Success response | 成功响应 + if (code === 200 || res.status === 200) { + return res.data + } + + // Error response | 错误响应 + window.$message?.error(message || 'Request failed') + return Promise.reject(res.data) + }, + (error) => { + const { response } = error + + if (response) { + const { status, data } = response + const message = data?.message || data?.error?.message || error.message + + if (status === 401) { + window.$message?.error('登录已过期,请重新进入工作台') + } else if (status === 429) { + window.$message?.error('请求过于频繁,请稍后再试') + } else { + window.$message?.error(message || '请求失败') + } + } else { + window.$message?.error(error.message || '网络错误') + } + + return Promise.reject(error) + } +) + +/** + * Set API base URL | 设置 API 基础 URL + * @param {string} url - Base URL + */ +export const setBaseUrl = (url) => { + instance.defaults.baseURL = url +} + +/** + * Get current base URL | 获取当前基础 URL + * @returns {string} + */ +export const getBaseUrl = () => { + return instance.defaults.baseURL +} + +export default instance diff --git a/web/canvas-app/src/utils/schema.js b/web/canvas-app/src/utils/schema.js new file mode 100644 index 0000000..dca9e8f --- /dev/null +++ b/web/canvas-app/src/utils/schema.js @@ -0,0 +1,105 @@ +/** + * API Utils | API 工具函数 + * Simplified for open source version | 开源版简化版 + */ + +/** + * Get nested value from object | 获取嵌套对象的值 + * @param {Object} obj - Source object + * @param {string} path - Path like "data.url" or "choices.0.message" + * @returns {*} Value at path + */ +export const getNestedValue = (obj, path) => { + if (!obj || !path) return obj + const paths = path.split('.') + let value = obj + for (const p of paths) { + value = value?.[p] + } + return value +} + +/** + * Build request body with FormData support | 构建请求体,支持 FormData + * @param {Object} params - Request parameters + * @param {string} requestType - 'json' or 'formdata' + * @returns {Object|FormData} Request body + */ +export const buildRequestBody = (params, requestType = 'json') => { + if (requestType !== 'formdata') { + return params + } + + const fd = new FormData() + + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + value.forEach((item, idx) => { + if (item instanceof File) { + fd.append(`${key}[${idx}]`, item, item.name) + } else if (typeof item === 'object' && item !== null) { + fd.append(`${key}[${idx}]`, JSON.stringify(item)) + } else { + fd.append(`${key}[${idx}]`, item) + } + }) + } else if (value instanceof File) { + fd.append(key, value, value.name) + } else if (typeof value === 'object' && value !== null) { + fd.append(key, JSON.stringify(value)) + } else if (value !== undefined && value !== null && value !== '') { + fd.append(key, value) + } + } + + return fd +} + +/** + * Parse API result based on output schema | 根据输出 schema 解析 API 结果 + * @param {Object} result - API response + * @param {Object} outputSchema - Output schema with displayField + * @param {string} resultType - Result type: 'image', 'video', 'chat' + * @returns {Array} Parsed results + */ +export const parseApiResult = (result, outputSchema, resultType = 'image') => { + if (!result) return [] + + // Default field based on result type + const defaultField = resultType === 'video' ? 'video_url' : (resultType === 'image' ? 'data' : null) + const displayField = outputSchema?.displayField || defaultField + + // No displayField, try default parsing + if (!displayField) { + if (result?.data) { + return Array.isArray(result.data) ? result.data : [result.data] + } + return [result] + } + + // Parse displayField path + // Supports: "data", "data[].url", "choices[].message.content" + if (displayField.includes('[]')) { + // Array path like data[].url + const [arrayPath, ...rest] = displayField.split('[]') + const fieldPath = rest.join('[]').replace(/^\./, '') // Remove leading dot + + // Get array + let data = arrayPath ? getNestedValue(result, arrayPath) : result + + if (!Array.isArray(data)) { + data = data ? [data] : [] + } + + // Extract field from each element if fieldPath exists + if (fieldPath) { + return data.map(item => getNestedValue(item, fieldPath)).filter(Boolean) + } + + return data + } else { + // Simple path like "data" + const data = getNestedValue(result, displayField) + return Array.isArray(data) ? data : (data ? [data] : []) + } +} diff --git a/web/canvas-app/src/views/Canvas.vue b/web/canvas-app/src/views/Canvas.vue new file mode 100644 index 0000000..e28568f --- /dev/null +++ b/web/canvas-app/src/views/Canvas.vue @@ -0,0 +1,929 @@ +