├── src
├── vite-env.d.ts
├── assets
│ └── logo.png
├── components
│ ├── panels
│ │ ├── index.ts
│ │ ├── NodePanel.tsx
│ │ └── KeyboardShortcutsPanel.tsx
│ ├── nodes
│ │ ├── index.ts
│ │ ├── PPTAssemblerNode
│ │ │ ├── types.ts
│ │ │ ├── ThumbnailStrip.tsx
│ │ │ └── PagePreview.tsx
│ │ ├── PPTContentNode
│ │ │ ├── ImageRefTag.tsx
│ │ │ ├── ImageSelectorModal.tsx
│ │ │ ├── PageList.tsx
│ │ │ └── BuiltinTemplateModal.tsx
│ │ ├── PromptNode.tsx
│ │ ├── ImageInputNode.tsx
│ │ └── FileUploadNode.tsx
│ └── ui
│ │ ├── Toast.tsx
│ │ ├── Input.tsx
│ │ ├── LoadingIndicator.tsx
│ │ ├── ContextMenu.tsx
│ │ ├── PromptEditorModal.tsx
│ │ ├── Select.tsx
│ │ └── ImagePreviewModal.tsx
├── main.tsx
├── hooks
│ └── useLoadingDots.ts
├── stores
│ ├── toastStore.ts
│ ├── settingsStore.ts
│ └── storageManagementStore.ts
├── utils
│ ├── imageCompression.ts
│ ├── tauriStorage.ts
│ └── connectionValidator.ts
├── services
│ ├── fileStorageService.ts
│ ├── updateService.ts
│ ├── ocrInpaintService.ts
│ ├── imageService.ts
│ └── taskManager.ts
├── App.tsx
├── config
│ └── nodeConfig.ts
├── types
│ └── index.ts
└── index.css
├── src-tauri
├── build.rs
├── icons
│ ├── 32x32.png
│ ├── 64x64.png
│ ├── icon.icns
│ ├── icon.ico
│ ├── icon.png
│ ├── 128x128.png
│ ├── 128x128@2x.png
│ ├── StoreLogo.png
│ ├── Square30x30Logo.png
│ ├── Square44x44Logo.png
│ ├── Square71x71Logo.png
│ ├── Square89x89Logo.png
│ ├── Square107x107Logo.png
│ ├── Square142x142Logo.png
│ ├── Square150x150Logo.png
│ ├── Square284x284Logo.png
│ ├── Square310x310Logo.png
│ ├── ios
│ │ ├── AppIcon-512@2x.png
│ │ ├── AppIcon-20x20@1x.png
│ │ ├── AppIcon-20x20@2x.png
│ │ ├── AppIcon-20x20@3x.png
│ │ ├── AppIcon-29x29@1x.png
│ │ ├── AppIcon-29x29@2x.png
│ │ ├── AppIcon-29x29@3x.png
│ │ ├── AppIcon-40x40@1x.png
│ │ ├── AppIcon-40x40@2x.png
│ │ ├── AppIcon-40x40@3x.png
│ │ ├── AppIcon-60x60@2x.png
│ │ ├── AppIcon-60x60@3x.png
│ │ ├── AppIcon-76x76@1x.png
│ │ ├── AppIcon-76x76@2x.png
│ │ ├── AppIcon-20x20@2x-1.png
│ │ ├── AppIcon-29x29@2x-1.png
│ │ ├── AppIcon-40x40@2x-1.png
│ │ └── AppIcon-83.5x83.5@2x.png
│ └── android
│ │ ├── mipmap-hdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_round.png
│ │ └── ic_launcher_foreground.png
│ │ ├── mipmap-mdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_round.png
│ │ └── ic_launcher_foreground.png
│ │ ├── mipmap-xhdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_round.png
│ │ └── ic_launcher_foreground.png
│ │ ├── mipmap-xxhdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_round.png
│ │ └── ic_launcher_foreground.png
│ │ ├── mipmap-xxxhdpi
│ │ ├── ic_launcher.png
│ │ ├── ic_launcher_round.png
│ │ └── ic_launcher_foreground.png
│ │ ├── values
│ │ └── ic_launcher_background.xml
│ │ └── mipmap-anydpi-v26
│ │ └── ic_launcher.xml
├── .gitignore
├── src
│ ├── main.rs
│ └── lib.rs
├── capabilities
│ └── default.json
├── Cargo.toml
└── tauri.conf.json
├── public
├── favicon.png
├── templates
│ └── template_1.png
├── vite.svg
└── tauri.svg
├── docs
└── images
│ ├── logo.png
│ ├── ppt-pages.png
│ ├── ppt-preview.png
│ ├── ppt-workflow.png
│ ├── main-interface.png
│ └── ppt-preview-original.png
├── .vscode
└── extensions.json
├── tsconfig.node.json
├── .gitignore
├── index.html
├── docker
├── Dockerfile.paddleocr
├── Dockerfile.iopaint
├── Dockerfile.easyocr
├── docker-compose.yml
├── README.md
└── ocr_server.py
├── tsconfig.json
├── package.json
├── vite.config.ts
├── .github
└── workflows
│ └── release.yml
└── README.md
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/src-tauri/build.rs:
--------------------------------------------------------------------------------
1 | fn main() {
2 | tauri_build::build()
3 | }
4 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/public/favicon.png
--------------------------------------------------------------------------------
/docs/images/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/logo.png
--------------------------------------------------------------------------------
/src/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src/assets/logo.png
--------------------------------------------------------------------------------
/docs/images/ppt-pages.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-pages.png
--------------------------------------------------------------------------------
/src-tauri/icons/32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/32x32.png
--------------------------------------------------------------------------------
/src-tauri/icons/64x64.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/64x64.png
--------------------------------------------------------------------------------
/src-tauri/icons/icon.icns:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/icon.icns
--------------------------------------------------------------------------------
/src-tauri/icons/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/icon.ico
--------------------------------------------------------------------------------
/src-tauri/icons/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/icon.png
--------------------------------------------------------------------------------
/docs/images/ppt-preview.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-preview.png
--------------------------------------------------------------------------------
/docs/images/ppt-workflow.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-workflow.png
--------------------------------------------------------------------------------
/src-tauri/icons/128x128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/128x128.png
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
3 | }
4 |
--------------------------------------------------------------------------------
/docs/images/main-interface.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/main-interface.png
--------------------------------------------------------------------------------
/src-tauri/icons/128x128@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/128x128@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/StoreLogo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/StoreLogo.png
--------------------------------------------------------------------------------
/public/templates/template_1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/public/templates/template_1.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square30x30Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square30x30Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square44x44Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square44x44Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square71x71Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square71x71Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square89x89Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square89x89Logo.png
--------------------------------------------------------------------------------
/docs/images/ppt-preview-original.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/docs/images/ppt-preview-original.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square107x107Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square107x107Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square142x142Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square142x142Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square150x150Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square150x150Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square284x284Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square284x284Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/Square310x310Logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/Square310x310Logo.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-512@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-512@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-60x60@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-60x60@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-60x60@3x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-60x60@3x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-76x76@1x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-76x76@1x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-76x76@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-76x76@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-20x20@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-29x29@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-40x40@2x-1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
--------------------------------------------------------------------------------
/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/MoonWeSif/NextCreator/HEAD/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png
--------------------------------------------------------------------------------
/src-tauri/icons/android/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #fff
4 |
--------------------------------------------------------------------------------
/src/components/panels/index.ts:
--------------------------------------------------------------------------------
1 | export { NodePanel } from "./NodePanel";
2 | export { SettingsPanel } from "./SettingsPanel";
3 | export { KeyboardShortcutsPanel } from "./KeyboardShortcutsPanel";
4 |
--------------------------------------------------------------------------------
/src-tauri/.gitignore:
--------------------------------------------------------------------------------
1 | # Generated by Cargo
2 | # will have compiled files and executables
3 | /target/
4 |
5 | # Generated by Tauri
6 | # will have schema files for capabilities auto-completion
7 | /gen/schemas
8 |
--------------------------------------------------------------------------------
/src-tauri/src/main.rs:
--------------------------------------------------------------------------------
1 | // Prevents additional console window on Windows in release, DO NOT REMOVE!!
2 | #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
3 |
4 | fn main() {
5 | nextcreator_lib::run()
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App";
4 | import "./index.css";
5 |
6 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
7 |
8 |
9 | ,
10 | );
11 |
--------------------------------------------------------------------------------
/src-tauri/icons/android/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | NextCreator
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/docker/Dockerfile.paddleocr:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim
2 |
3 | WORKDIR /app
4 |
5 | # 安装系统依赖 (libgl1-mesa-glx 在新版 Debian 中改名为 libgl1)
6 | RUN apt-get update && apt-get install -y \
7 | libgl1 \
8 | libglib2.0-0 \
9 | libsm6 \
10 | libxext6 \
11 | libxrender-dev \
12 | && rm -rf /var/lib/apt/lists/*
13 |
14 | # 安装 Python 依赖
15 | RUN pip install --no-cache-dir \
16 | paddlepaddle \
17 | paddleocr \
18 | flask
19 |
20 | # 复制服务代码
21 | COPY ocr_server.py /app/
22 |
23 | EXPOSE 8866
24 |
25 | CMD ["python", "ocr_server.py"]
26 |
--------------------------------------------------------------------------------
/docker/Dockerfile.iopaint:
--------------------------------------------------------------------------------
1 | # IOPaint CPU Dockerfile
2 | # 基于官方 https://github.com/Sanster/IOPaint/blob/main/docker/CPUDockerfile
3 |
4 | FROM python:3.10-slim
5 |
6 | ARG version=1.5.3
7 |
8 | # 安装 OpenCV 所需的系统依赖 (libGL)
9 | RUN apt-get update && apt-get install -y \
10 | libgl1 \
11 | libglib2.0-0 \
12 | && rm -rf /var/lib/apt/lists/*
13 |
14 | # 升级 pip 并使用重试机制安装
15 | RUN pip install --upgrade pip && \
16 | pip install --no-cache-dir --retries 5 --timeout 60 iopaint==${version}
17 |
18 | EXPOSE 8080
19 |
20 | CMD ["iopaint", "start", "--host", "0.0.0.0", "--port", "8080", "--model", "lama", "--device", "cpu"]
21 |
--------------------------------------------------------------------------------
/docker/Dockerfile.easyocr:
--------------------------------------------------------------------------------
1 | # EasyOCR Dockerfile
2 | # 轻量级 OCR 服务,对 ARM 架构兼容性好
3 |
4 | FROM python:3.10-slim
5 |
6 | WORKDIR /app
7 |
8 | # 安装系统依赖(OpenCV 需要)
9 | RUN apt-get update && apt-get install -y \
10 | libgl1 \
11 | libglib2.0-0 \
12 | && rm -rf /var/lib/apt/lists/*
13 |
14 | # 安装 Python 依赖
15 | # 使用 --extra-index-url 添加 PyTorch CPU 源,同时保留 PyPI 源
16 | RUN pip install --no-cache-dir \
17 | torch \
18 | --extra-index-url https://download.pytorch.org/whl/cpu
19 |
20 | RUN pip install --no-cache-dir \
21 | easyocr \
22 | flask
23 |
24 | # 复制服务代码
25 | COPY ocr_server.py /app/
26 |
27 | EXPOSE 8866
28 |
29 | CMD ["python", "ocr_server.py"]
30 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Path Aliases */
18 | "baseUrl": ".",
19 | "paths": {
20 | "@/*": ["./src/*"]
21 | },
22 |
23 | /* Linting */
24 | "strict": true,
25 | "noUnusedLocals": true,
26 | "noUnusedParameters": true,
27 | "noFallthroughCasesInSwitch": true
28 | },
29 | "include": ["src"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/src/hooks/useLoadingDots.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | /**
4 | * 省略号加载动画 Hook
5 | * 使用 React state 驱动,不触发 GPU 合成,避免字体模糊
6 | * @param isLoading - 是否正在加载
7 | * @param interval - 切换间隔,默认 500ms
8 | * @returns 当前省略号字符串 ("." | ".." | "...")
9 | */
10 | export function useLoadingDots(isLoading: boolean, interval = 500): string {
11 | const [dots, setDots] = useState(".");
12 |
13 | useEffect(() => {
14 | if (!isLoading) {
15 | setDots(".");
16 | return;
17 | }
18 |
19 | const timer = setInterval(() => {
20 | setDots(prev => {
21 | if (prev === ".") return "..";
22 | if (prev === "..") return "...";
23 | return ".";
24 | });
25 | }, interval);
26 |
27 | return () => clearInterval(timer);
28 | }, [isLoading, interval]);
29 |
30 | return dots;
31 | }
32 |
--------------------------------------------------------------------------------
/src-tauri/capabilities/default.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "../gen/schemas/desktop-schema.json",
3 | "identifier": "default",
4 | "description": "Capability for the main window",
5 | "windows": ["main"],
6 | "permissions": [
7 | "core:default",
8 | "opener:default",
9 | "dialog:default",
10 | "fs:default",
11 | "fs:allow-download-write",
12 | "fs:allow-download-write-recursive",
13 | "fs:allow-desktop-write",
14 | "fs:allow-desktop-write-recursive",
15 | "fs:allow-document-write",
16 | "fs:allow-document-write-recursive",
17 | "fs:allow-home-write",
18 | "fs:allow-home-write-recursive",
19 | "fs:allow-download-read",
20 | "fs:allow-desktop-read",
21 | "fs:allow-document-read",
22 | "fs:allow-home-read",
23 | "fs:write-all",
24 | "fs:read-all",
25 | "store:default"
26 | ]
27 | }
28 |
--------------------------------------------------------------------------------
/src-tauri/src/lib.rs:
--------------------------------------------------------------------------------
1 | mod storage;
2 | mod gemini;
3 | mod ocr_inpaint;
4 |
5 | use storage::*;
6 | use gemini::*;
7 | use ocr_inpaint::*;
8 |
9 | #[cfg_attr(mobile, tauri::mobile_entry_point)]
10 | pub fn run() {
11 | tauri::Builder::default()
12 | .plugin(tauri_plugin_opener::init())
13 | .plugin(tauri_plugin_dialog::init())
14 | .plugin(tauri_plugin_fs::init())
15 | .plugin(tauri_plugin_store::Builder::default().build())
16 | .invoke_handler(tauri::generate_handler![
17 | save_image,
18 | read_image,
19 | delete_image,
20 | delete_canvas_images,
21 | get_storage_stats,
22 | clear_cache,
23 | clear_all_images,
24 | get_storage_path,
25 | list_canvas_images,
26 | gemini_generate_content,
27 | gemini_generate_text,
28 | process_ppt_page,
29 | test_ocr_connection,
30 | test_inpaint_connection
31 | ])
32 | .run(tauri::generate_context!())
33 | .expect("error while running tauri application");
34 | }
35 |
--------------------------------------------------------------------------------
/src-tauri/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "nextcreator"
3 | version = "0.1.1"
4 | description = "A Tauri App"
5 | authors = ["you"]
6 | edition = "2021"
7 |
8 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
9 |
10 | [lib]
11 | # The `_lib` suffix may seem redundant but it is necessary
12 | # to make the lib name unique and wouldn't conflict with the bin name.
13 | # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
14 | name = "nextcreator_lib"
15 | crate-type = ["staticlib", "cdylib", "rlib"]
16 |
17 | [build-dependencies]
18 | tauri-build = { version = "2", features = [] }
19 |
20 | [dependencies]
21 | tauri = { version = "2", features = ["protocol-asset"] }
22 | tauri-plugin-opener = "2"
23 | tauri-plugin-dialog = "2"
24 | tauri-plugin-fs = "2"
25 | serde = { version = "1", features = ["derive"] }
26 | serde_json = "1"
27 | base64 = "0.22"
28 | uuid = { version = "1", features = ["v4"] }
29 | chrono = "0.4"
30 | reqwest = { version = "0.12", features = ["json"] }
31 | tokio = { version = "1", features = ["full"] }
32 | image = "0.25"
33 | tauri-plugin-store = "2.4.1"
34 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextcreator",
3 | "private": true,
4 | "version": "0.1.1",
5 | "license": "AGPL-3.0",
6 | "type": "module",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "tsc && vite build",
10 | "preview": "vite preview",
11 | "tauri": "tauri"
12 | },
13 | "dependencies": {
14 | "@google/genai": "^1.32.0",
15 | "@tailwindcss/vite": "^4.1.17",
16 | "@tauri-apps/api": "^2",
17 | "@tauri-apps/plugin-dialog": "^2.4.2",
18 | "@tauri-apps/plugin-fs": "^2.4.4",
19 | "@tauri-apps/plugin-opener": "^2",
20 | "@tauri-apps/plugin-store": "^2.4.1",
21 | "@xyflow/react": "^12.10.0",
22 | "daisyui": "^5.5.8",
23 | "lucide-react": "^0.556.0",
24 | "pptxgenjs": "^4.0.1",
25 | "react": "^19.1.0",
26 | "react-dom": "^19.1.0",
27 | "react-markdown": "^10.1.0",
28 | "tailwindcss": "^4.1.17",
29 | "uuid": "^13.0.0",
30 | "zustand": "^5.0.9"
31 | },
32 | "devDependencies": {
33 | "@tauri-apps/cli": "^2",
34 | "@types/react": "^19.1.8",
35 | "@types/react-dom": "^19.1.6",
36 | "@types/uuid": "^11.0.0",
37 | "@vitejs/plugin-react": "^4.6.0",
38 | "typescript": "~5.8.3",
39 | "vite": "^7.0.4"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src-tauri/tauri.conf.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://schema.tauri.app/config/2",
3 | "productName": "NextCreator",
4 | "version": "0.1.1",
5 | "identifier": "com.sy.nextcreator",
6 | "build": {
7 | "beforeDevCommand": "bun run dev",
8 | "devUrl": "http://localhost:1420",
9 | "beforeBuildCommand": "bun run build",
10 | "frontendDist": "../dist"
11 | },
12 | "app": {
13 | "windows": [
14 | {
15 | "title": "NextCreator",
16 | "width": 1400,
17 | "height": 900,
18 | "minWidth": 1000,
19 | "minHeight": 700,
20 | "center": true,
21 | "resizable": true,
22 | "fullscreen": false,
23 | "dragDropEnabled": false,
24 | "useHttpsScheme": true
25 | }
26 | ],
27 | "security": {
28 | "csp": null,
29 | "assetProtocol": {
30 | "enable": true,
31 | "scope": ["$APPDATA/**", "$RESOURCE/**"]
32 | }
33 | }
34 | },
35 | "bundle": {
36 | "active": true,
37 | "targets": "all",
38 | "icon": [
39 | "icons/32x32.png",
40 | "icons/128x128.png",
41 | "icons/128x128@2x.png",
42 | "icons/icon.icns",
43 | "icons/icon.ico"
44 | ]
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/docker/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # NextCreator - OCR + Inpaint 服务
2 | # 用于 PPT 可编辑导出功能
3 | #
4 | # 使用方法:
5 | # cd docker
6 | # docker-compose up -d
7 | #
8 | # 服务地址:
9 | # - EasyOCR: http://127.0.0.1:8866
10 | # - IOPaint: http://127.0.0.1:8080
11 | #
12 | # 首次启动需要下载模型,请耐心等待(约 3-5 分钟)
13 |
14 | services:
15 | # EasyOCR 服务 - 文字检测和识别(轻量级,ARM 兼容)
16 | easyocr:
17 | build:
18 | context: .
19 | dockerfile: Dockerfile.easyocr
20 | container_name: next-easyocr
21 | ports:
22 | - "8866:8866"
23 | volumes:
24 | - easyocr_models:/root/.EasyOCR
25 | restart: unless-stopped
26 | deploy:
27 | resources:
28 | limits:
29 | memory: 4G
30 | reservations:
31 | memory: 1G
32 |
33 | # IOPaint 服务 - AI 背景修复
34 | iopaint:
35 | build:
36 | context: .
37 | dockerfile: Dockerfile.iopaint
38 | container_name: next-iopaint
39 | ports:
40 | - "8080:8080"
41 | volumes:
42 | - iopaint_models:/root/.cache
43 | restart: unless-stopped
44 | deploy:
45 | resources:
46 | limits:
47 | memory: 4G
48 | reservations:
49 | memory: 1G
50 |
51 | volumes:
52 | easyocr_models:
53 | iopaint_models:
54 |
--------------------------------------------------------------------------------
/docker/README.md:
--------------------------------------------------------------------------------
1 | # NextCreator - OCR + Inpaint 服务部署
2 |
3 | 用于 PPT 可编辑导出功能的后端服务。
4 |
5 | ## 快速启动
6 |
7 | ```bash
8 | cd docker
9 | docker-compose up -d
10 | ```
11 |
12 | ## 服务说明
13 |
14 | | 服务 | 端口 | 用途 |
15 | |------|------|------|
16 | | EasyOCR | 8866 | 文字检测和识别 |
17 | | IOPaint | 8080 | AI 背景修复(去除文字) |
18 |
19 | ## 首次启动
20 |
21 | 首次启动需要下载模型文件,可能需要 3-5 分钟。
22 |
23 | 查看启动日志:
24 | ```bash
25 | docker-compose logs -f
26 | ```
27 |
28 | ## 测试服务
29 |
30 | ### 测试 EasyOCR
31 | ```bash
32 | curl http://127.0.0.1:8866/
33 | # 返回: {"languages":["ch_sim","en"],"service":"EasyOCR","status":"ok"}
34 | ```
35 |
36 | ### 测试 IOPaint
37 | ```bash
38 | curl http://127.0.0.1:8080/
39 | # 返回 IOPaint 配置信息
40 | ```
41 |
42 | ## 在 NextCreator 中配置
43 |
44 | 1. 打开 PPT 组装节点
45 | 2. 点击右上角设置按钮
46 | 3. 填入服务地址:
47 | - OCR 服务: `http://127.0.0.1:8866`
48 | - IOPaint 服务: `http://127.0.0.1:8080`
49 | 4. 点击「测试」验证连接
50 | 5. 切换到「可编辑」导出模式
51 |
52 | ## 停止服务
53 |
54 | ```bash
55 | docker-compose down
56 | ```
57 |
58 | ## 常见问题
59 |
60 | ### Q: 内存占用过高
61 | A: 可以在 `docker-compose.yml` 中调整 `memory` 限制
62 |
63 | ### Q: 处理速度慢
64 | A: 默认使用 CPU,如有 GPU 可修改相关配置
65 |
66 | ### Q: 模型下载失败
67 | A: 检查网络连接,或配置代理
68 |
69 | ## 技术说明
70 |
71 | - **EasyOCR**: 基于 PyTorch,支持 80+ 语言,对 ARM 架构兼容性好
72 | - **IOPaint**: 使用 LaMa 模型进行图像修复,去除文字区域
73 |
--------------------------------------------------------------------------------
/src/components/nodes/index.ts:
--------------------------------------------------------------------------------
1 | export { PromptNode } from "./PromptNode";
2 | export { ImageGeneratorProNode, ImageGeneratorFastNode } from "./ImageGeneratorNode";
3 | export { ImageInputNode } from "./ImageInputNode";
4 | export { VideoGeneratorNode } from "./VideoGeneratorNode";
5 | export { PPTContentNode } from "./PPTContentNode";
6 | export { PPTAssemblerNode } from "./PPTAssemblerNode";
7 | export { LLMContentNode } from "./LLMContentNode";
8 | export { FileUploadNode } from "./FileUploadNode";
9 |
10 | import { PromptNode } from "./PromptNode";
11 | import { ImageGeneratorProNode, ImageGeneratorFastNode } from "./ImageGeneratorNode";
12 | import { ImageInputNode } from "./ImageInputNode";
13 | import { VideoGeneratorNode } from "./VideoGeneratorNode";
14 | import { PPTContentNode } from "./PPTContentNode";
15 | import { PPTAssemblerNode } from "./PPTAssemblerNode";
16 | import { LLMContentNode } from "./LLMContentNode";
17 | import { FileUploadNode } from "./FileUploadNode";
18 |
19 | // 节点类型映射
20 | export const nodeTypes = {
21 | promptNode: PromptNode,
22 | imageGeneratorProNode: ImageGeneratorProNode,
23 | imageGeneratorFastNode: ImageGeneratorFastNode,
24 | imageInputNode: ImageInputNode,
25 | videoGeneratorNode: VideoGeneratorNode,
26 | pptContentNode: PPTContentNode,
27 | pptAssemblerNode: PPTAssemblerNode,
28 | llmContentNode: LLMContentNode,
29 | fileUploadNode: FileUploadNode,
30 | };
31 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTAssemblerNode/types.ts:
--------------------------------------------------------------------------------
1 | // PPT 组装节点类型定义
2 |
3 | // PPT 页面数据(从上游接收)
4 | export interface PPTPageData {
5 | pageNumber: number;
6 | heading: string;
7 | points: string[];
8 | script: string;
9 | image: string; // base64 图片 - 完整的 PPT 页面图片(用于导出)
10 | thumbnail?: string; // 缩略图 base64(JPEG 格式,用于画布预览)
11 |
12 | // 仅背景模式处理后的数据
13 | processedBackground?: string; // 处理后的背景图 base64(去除文字后)
14 | processedThumbnail?: string; // 处理后的背景图缩略图
15 | processStatus?: 'pending' | 'processing' | 'completed' | 'error'; // 处理状态
16 | processError?: string; // 处理错误信息
17 | }
18 |
19 | // PPT 组装节点数据
20 | export interface PPTAssemblerNodeData {
21 | [key: string]: unknown;
22 | label: string;
23 |
24 | // 幻灯片比例
25 | aspectRatio: "16:9" | "4:3";
26 |
27 | // 页面数据(从上游同步)
28 | pages: PPTPageData[];
29 |
30 | // 状态
31 | status: "idle" | "generating" | "processing" | "ready" | "error";
32 | error?: string;
33 |
34 | // === 可编辑导出功能 ===
35 | // 导出模式:
36 | // - image: 纯图片(原始图片直接嵌入)
37 | // - background: 仅背景(去除文字后的背景图,用户自行添加文字)
38 | exportMode: "image" | "background";
39 |
40 | // OCR 服务地址
41 | ocrApiUrl: string;
42 |
43 | // IOPaint 服务地址
44 | inpaintApiUrl: string;
45 |
46 | // 处理进度(当前处理页面索引和详细步骤)
47 | processingProgress?: {
48 | current: number;
49 | total: number;
50 | currentStep?: 'ocr' | 'inpaint'; // 当前步骤:OCR识别 或 背景修复
51 | } | null;
52 | }
53 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "vite";
2 | import react from "@vitejs/plugin-react";
3 | import tailwindcss from "@tailwindcss/vite";
4 | import path from "path";
5 | import { readFileSync } from "fs";
6 |
7 | // @ts-expect-error process is a nodejs global
8 | const host = process.env.TAURI_DEV_HOST;
9 |
10 | // 读取 package.json 获取版本号
11 | const packageJson = JSON.parse(
12 | readFileSync(path.resolve(__dirname, "package.json"), "utf-8")
13 | );
14 |
15 | // https://vite.dev/config/
16 | export default defineConfig(async () => ({
17 | plugins: [react(), tailwindcss()],
18 | // 注入版本号到应用
19 | define: {
20 | __APP_VERSION__: JSON.stringify(packageJson.version),
21 | },
22 | resolve: {
23 | alias: {
24 | "@": path.resolve(__dirname, "./src"),
25 | },
26 | },
27 |
28 | // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
29 | //
30 | // 1. prevent Vite from obscuring rust errors
31 | clearScreen: false,
32 | // 2. tauri expects a fixed port, fail if that port is not available
33 | server: {
34 | port: 1420,
35 | strictPort: true,
36 | host: host || false,
37 | hmr: host
38 | ? {
39 | protocol: "ws",
40 | host,
41 | port: 1421,
42 | }
43 | : undefined,
44 | watch: {
45 | // 3. tell Vite to ignore watching `src-tauri`
46 | ignored: ["**/src-tauri/**"],
47 | },
48 | },
49 | }));
50 |
--------------------------------------------------------------------------------
/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTAssemblerNode/ThumbnailStrip.tsx:
--------------------------------------------------------------------------------
1 | import type { PPTPageData } from "./types";
2 |
3 | interface ThumbnailStripProps {
4 | pages: PPTPageData[];
5 | currentPage: number;
6 | onPageSelect: (index: number) => void;
7 | }
8 |
9 | export function ThumbnailStrip({
10 | pages,
11 | currentPage,
12 | onPageSelect,
13 | }: ThumbnailStripProps) {
14 | if (pages.length === 0) {
15 | return null;
16 | }
17 |
18 | return (
19 |
20 | {pages.map((page, index) => {
21 | // 优先使用缩略图,减少内存占用
22 | const imageUrl = page.thumbnail
23 | ? `data:image/jpeg;base64,${page.thumbnail}`
24 | : page.image
25 | ? `data:image/png;base64,${page.image}`
26 | : undefined;
27 |
28 | return (
29 |
53 | );
54 | })}
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/src/components/ui/Toast.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Toast 通知组件
3 | * 使用 daisyUI alert 样式
4 | */
5 |
6 | import { createPortal } from "react-dom";
7 | import { CheckCircle, XCircle, AlertTriangle, Info, X } from "lucide-react";
8 | import { useToastStore, type ToastType } from "@/stores/toastStore";
9 |
10 | // Toast 类型配置
11 | const toastConfig: Record = {
15 | success: {
16 | icon: ,
17 | alertClass: "alert-success",
18 | },
19 | error: {
20 | icon: ,
21 | alertClass: "alert-error",
22 | },
23 | warning: {
24 | icon: ,
25 | alertClass: "alert-warning",
26 | },
27 | info: {
28 | icon: ,
29 | alertClass: "alert-info",
30 | },
31 | };
32 |
33 | export function ToastContainer() {
34 | const { toasts, removeToast } = useToastStore();
35 |
36 | if (toasts.length === 0) return null;
37 |
38 | return createPortal(
39 |
40 | {toasts.map((toast) => {
41 | const config = toastConfig[toast.type];
42 | return (
43 |
51 | {config.icon}
52 | {toast.message}
53 |
59 |
60 | );
61 | })}
62 |
,
63 | document.body
64 | );
65 | }
66 |
67 | export default ToastContainer;
68 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: 'Release'
2 |
3 | # 当推送版本 tag 时触发(如 v0.1.0, v1.0.0 等)
4 | on:
5 | push:
6 | tags:
7 | - 'v*'
8 |
9 | jobs:
10 | release:
11 | permissions:
12 | contents: write
13 | strategy:
14 | fail-fast: false
15 | matrix:
16 | include:
17 | # macOS Apple Silicon (M1/M2/M3)
18 | - platform: 'macos-latest'
19 | args: '--target aarch64-apple-darwin'
20 | # macOS Intel
21 | - platform: 'macos-latest'
22 | args: '--target x86_64-apple-darwin'
23 | # Windows
24 | - platform: 'windows-latest'
25 | args: ''
26 |
27 | runs-on: ${{ matrix.platform }}
28 | steps:
29 | - name: Checkout repository
30 | uses: actions/checkout@v4
31 |
32 | - name: Setup Node.js
33 | uses: actions/setup-node@v4
34 | with:
35 | node-version: 'lts/*'
36 |
37 | - name: Setup Bun
38 | uses: oven-sh/setup-bun@v2
39 | with:
40 | bun-version: latest
41 |
42 | - name: Install Rust stable
43 | uses: dtolnay/rust-toolchain@stable
44 | with:
45 | # macOS 需要同时支持 Intel 和 Apple Silicon 架构
46 | targets: ${{ matrix.platform == 'macos-latest' && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
47 |
48 | - name: Rust cache
49 | uses: swatinem/rust-cache@v2
50 | with:
51 | workspaces: './src-tauri -> target'
52 |
53 | - name: Install frontend dependencies
54 | run: bun install
55 |
56 | - name: Build and release
57 | uses: tauri-apps/tauri-action@v0
58 | env:
59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60 | with:
61 | tagName: ${{ github.ref_name }}
62 | releaseName: 'NextCreator ${{ github.ref_name }}'
63 | releaseBody: |
64 | ## NextCreator ${{ github.ref_name }}
65 |
66 | 请根据您的系统下载对应的安装包:
67 |
68 | - **macOS (Apple Silicon M1/M2/M3)**: `NextCreator_*_aarch64.dmg`
69 | - **macOS (Intel)**: `NextCreator_*_x64.dmg`
70 | - **Windows**: `NextCreator_*_x64-setup.exe` 或 `.msi`
71 | releaseDraft: false
72 | prerelease: false
73 | args: ${{ matrix.args }}
74 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTContentNode/ImageRefTag.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState } from "react";
2 | import { X, Maximize2 } from "lucide-react";
3 | import { ImagePreviewModal } from "@/components/ui/ImagePreviewModal";
4 |
5 | interface ImageRefTagProps {
6 | id: string;
7 | fileName: string;
8 | imageData: string;
9 | onRemove: (id: string) => void;
10 | }
11 |
12 | // 图片引用标签组件 - 显示缩略图和文件名,支持预览和删除
13 | export const ImageRefTag = memo(({ id, fileName, imageData, onRemove }: ImageRefTagProps) => {
14 | const [showPreview, setShowPreview] = useState(false);
15 |
16 | return (
17 | <>
18 |
19 | {/* 缩略图 */}
20 |
setShowPreview(true)}
23 | >
24 |

29 |
30 |
31 |
32 |
33 |
34 | {/* 文件名 */}
35 |
setShowPreview(true)}
38 | title={fileName}
39 | >
40 | {fileName}
41 |
42 |
43 | {/* 删除按钮 */}
44 |
54 |
55 |
56 | {/* 预览弹窗 */}
57 | {showPreview && (
58 | setShowPreview(false)}
62 | />
63 | )}
64 | >
65 | );
66 | });
67 |
68 | ImageRefTag.displayName = "ImageRefTag";
69 |
--------------------------------------------------------------------------------
/src/stores/toastStore.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Toast 通知状态管理
3 | * 使用 Zustand 管理全局 toast 消息
4 | */
5 |
6 | import { create } from "zustand";
7 |
8 | // Toast 类型
9 | export type ToastType = "success" | "error" | "warning" | "info";
10 |
11 | // Toast 消息
12 | export interface ToastMessage {
13 | id: string;
14 | type: ToastType;
15 | message: string;
16 | duration?: number; // 持续时间(毫秒),默认 3000
17 | }
18 |
19 | interface ToastState {
20 | toasts: ToastMessage[];
21 | // 添加 toast
22 | addToast: (type: ToastType, message: string, duration?: number) => void;
23 | // 移除 toast
24 | removeToast: (id: string) => void;
25 | // 快捷方法
26 | success: (message: string, duration?: number) => void;
27 | error: (message: string, duration?: number) => void;
28 | warning: (message: string, duration?: number) => void;
29 | info: (message: string, duration?: number) => void;
30 | }
31 |
32 | export const useToastStore = create((set, get) => ({
33 | toasts: [],
34 |
35 | addToast: (type, message, duration = 3000) => {
36 | const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
37 | const toast: ToastMessage = { id, type, message, duration };
38 |
39 | set((state) => ({
40 | toasts: [...state.toasts, toast],
41 | }));
42 |
43 | // 自动移除
44 | if (duration > 0) {
45 | setTimeout(() => {
46 | get().removeToast(id);
47 | }, duration);
48 | }
49 | },
50 |
51 | removeToast: (id) => {
52 | set((state) => ({
53 | toasts: state.toasts.filter((t) => t.id !== id),
54 | }));
55 | },
56 |
57 | // 快捷方法
58 | success: (message, duration) => get().addToast("success", message, duration),
59 | error: (message, duration) => get().addToast("error", message, duration ?? 5000),
60 | warning: (message, duration) => get().addToast("warning", message, duration),
61 | info: (message, duration) => get().addToast("info", message, duration),
62 | }));
63 |
64 | // 导出便捷函数,可以在非 React 组件中使用
65 | export const toast = {
66 | success: (message: string, duration?: number) =>
67 | useToastStore.getState().success(message, duration),
68 | error: (message: string, duration?: number) =>
69 | useToastStore.getState().error(message, duration),
70 | warning: (message: string, duration?: number) =>
71 | useToastStore.getState().warning(message, duration),
72 | info: (message: string, duration?: number) =>
73 | useToastStore.getState().info(message, duration),
74 | };
75 |
--------------------------------------------------------------------------------
/src/components/ui/Input.tsx:
--------------------------------------------------------------------------------
1 | import { forwardRef, useState } from "react";
2 | import { Eye, EyeOff, Search } from "lucide-react";
3 |
4 | interface InputProps extends Omit, "size"> {
5 | /** 输入框左侧图标 */
6 | leftIcon?: React.ReactNode;
7 | /** 是否为密码输入框(支持显示/隐藏切换) */
8 | isPassword?: boolean;
9 | /** 是否为搜索框 */
10 | isSearch?: boolean;
11 | }
12 |
13 | export const Input = forwardRef(
14 | ({ className = "", leftIcon, isPassword, isSearch, type, ...props }, ref) => {
15 | const [showPassword, setShowPassword] = useState(false);
16 |
17 | // 确定实际的 type
18 | const inputType = isPassword ? (showPassword ? "text" : "password") : type;
19 |
20 | // 确定左侧图标
21 | const LeftIconComponent = isSearch ? : leftIcon;
22 |
23 | return (
24 |
25 | {/* 左侧图标 */}
26 | {LeftIconComponent && (
27 |
28 | {LeftIconComponent}
29 |
30 | )}
31 |
32 | {/* 输入框 */}
33 |
49 |
50 | {/* 密码显示/隐藏按钮 */}
51 | {isPassword && (
52 |
64 | )}
65 |
66 | );
67 | }
68 | );
69 |
70 | Input.displayName = "Input";
71 |
--------------------------------------------------------------------------------
/public/tauri.svg:
--------------------------------------------------------------------------------
1 |
7 |
--------------------------------------------------------------------------------
/src/utils/imageCompression.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 图片压缩工具
3 | * 用于生成缩略图,优化画布预览性能
4 | */
5 |
6 | export interface ThumbnailOptions {
7 | maxWidth?: number; // 最大宽度,默认 800px
8 | quality?: number; // JPEG 质量,0-1,默认 0.85
9 | format?: "jpeg" | "webp"; // 输出格式,默认 jpeg
10 | }
11 |
12 | /**
13 | * 从 base64 图片生成缩略图
14 | * @param base64Data - 原图 base64(不含 data:image/xxx;base64, 前缀)
15 | * @param options - 压缩选项
16 | * @returns 缩略图的 base64(不含前缀)
17 | */
18 | export function generateThumbnail(
19 | base64Data: string,
20 | options: ThumbnailOptions = {}
21 | ): Promise {
22 | const { maxWidth = 800, quality = 0.85, format = "jpeg" } = options;
23 |
24 | return new Promise((resolve, reject) => {
25 | const img = new Image();
26 |
27 | img.onload = () => {
28 | try {
29 | // 计算缩放后的尺寸
30 | let width = img.width;
31 | let height = img.height;
32 |
33 | if (width > maxWidth) {
34 | height = Math.round((height * maxWidth) / width);
35 | width = maxWidth;
36 | }
37 |
38 | // 创建 canvas 并绘制
39 | const canvas = document.createElement("canvas");
40 | canvas.width = width;
41 | canvas.height = height;
42 |
43 | const ctx = canvas.getContext("2d");
44 | if (!ctx) {
45 | reject(new Error("无法创建 canvas context"));
46 | return;
47 | }
48 |
49 | // 使用高质量缩放
50 | ctx.imageSmoothingEnabled = true;
51 | ctx.imageSmoothingQuality = "high";
52 | ctx.drawImage(img, 0, 0, width, height);
53 |
54 | // 转换为目标格式
55 | const mimeType = format === "webp" ? "image/webp" : "image/jpeg";
56 | const dataUrl = canvas.toDataURL(mimeType, quality);
57 |
58 | // 移除 data:image/xxx;base64, 前缀
59 | const base64 = dataUrl.split(",")[1];
60 | resolve(base64);
61 | } catch (error) {
62 | reject(error);
63 | }
64 | };
65 |
66 | img.onerror = () => {
67 | reject(new Error("图片加载失败"));
68 | };
69 |
70 | // 加载原图
71 | img.src = `data:image/png;base64,${base64Data}`;
72 | });
73 | }
74 |
75 | /**
76 | * 批量生成缩略图
77 | * @param images - base64 图片数组
78 | * @param options - 压缩选项
79 | * @returns 缩略图 base64 数组
80 | */
81 | export async function generateThumbnails(
82 | images: string[],
83 | options: ThumbnailOptions = {}
84 | ): Promise {
85 | return Promise.all(images.map((img) => generateThumbnail(img, options)));
86 | }
87 |
88 | /**
89 | * 获取缩略图的 MIME 类型
90 | * @param format - 格式
91 | * @returns MIME 类型字符串
92 | */
93 | export function getThumbnailMimeType(format: "jpeg" | "webp" = "jpeg"): string {
94 | return format === "webp" ? "image/webp" : "image/jpeg";
95 | }
96 |
--------------------------------------------------------------------------------
/src/utils/tauriStorage.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Tauri Store 存储适配器
3 | * 用于 Zustand persist 中间件,在 Tauri 环境使用 tauri-plugin-store
4 | * 浏览器环境降级到 localStorage
5 | */
6 |
7 | import { isTauriEnvironment } from "@/services/fileStorageService";
8 |
9 | // Store 实例缓存
10 | let storeInstance: Awaited> | null = null;
11 |
12 | // 获取或创建 Store 实例
13 | async function getStore() {
14 | if (!isTauriEnvironment()) {
15 | return null;
16 | }
17 |
18 | if (!storeInstance) {
19 | const { load } = await import("@tauri-apps/plugin-store");
20 | // 使用 load 函数加载或创建 store 文件
21 | storeInstance = await load("app-data.json", { autoSave: true, defaults: {} });
22 | }
23 |
24 | return storeInstance;
25 | }
26 |
27 | // 获取数据
28 | async function getItem(key: string): Promise {
29 | try {
30 | const store = await getStore();
31 |
32 | if (store) {
33 | // Tauri 环境:从 store 读取
34 | const value = await store.get(key);
35 | return value ?? null;
36 | } else {
37 | // 浏览器环境:降级到 localStorage
38 | return localStorage.getItem(key);
39 | }
40 | } catch (error) {
41 | console.error("Storage getItem error:", error);
42 | // 出错时尝试 localStorage
43 | try {
44 | return localStorage.getItem(key);
45 | } catch {
46 | return null;
47 | }
48 | }
49 | }
50 |
51 | // 设置数据
52 | async function setItem(key: string, value: string): Promise {
53 | try {
54 | const store = await getStore();
55 |
56 | if (store) {
57 | // Tauri 环境:写入 store
58 | await store.set(key, value);
59 | // autoSave 已启用,无需手动 save
60 | } else {
61 | // 浏览器环境:降级到 localStorage
62 | localStorage.setItem(key, value);
63 | }
64 | } catch (error) {
65 | console.error("Storage setItem error:", error);
66 | // 出错时尝试 localStorage
67 | try {
68 | localStorage.setItem(key, value);
69 | } catch {
70 | // 忽略
71 | }
72 | }
73 | }
74 |
75 | // 删除数据
76 | async function removeItem(key: string): Promise {
77 | try {
78 | const store = await getStore();
79 |
80 | if (store) {
81 | // Tauri 环境:从 store 删除
82 | await store.delete(key);
83 | } else {
84 | // 浏览器环境:降级到 localStorage
85 | localStorage.removeItem(key);
86 | }
87 | } catch (error) {
88 | console.error("Storage removeItem error:", error);
89 | // 出错时尝试 localStorage
90 | try {
91 | localStorage.removeItem(key);
92 | } catch {
93 | // 忽略
94 | }
95 | }
96 | }
97 |
98 | // 导出符合 Zustand StateStorage 接口的对象
99 | export const tauriStorage = {
100 | getItem,
101 | setItem,
102 | removeItem,
103 | };
104 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |

3 |
NextCreator
4 |
基于可视化节点的 AI 内容生成工作流工具
5 |
6 | 
7 | 
8 | 
9 | 
10 | 
11 |
12 |
13 | ---
14 |
15 | ## 功能特性
16 |
17 | - **节点编辑器** - 拖拽式工作流设计,支持撤销/重做、复制粘贴、自动布局
18 | - **多画布管理** - 创建多个独立画布,数据自动持久化
19 | - **AI 图片生成** - 支持文生图、图生图,可配置分辨率和比例
20 | - **AI 视频生成** - 基于 Sora 模型的视频生成
21 | - **LLM 文本生成** - 支持多模态输入(文本/图片/PDF)
22 | - **PPT 工作流** - 自动生成大纲、PPT页面,导出可编辑文字的 PPTX
23 |
24 | ## 截图预览
25 |
26 | ### 主界面
27 | 
28 |
29 | ### PPT 工作流
30 | 
31 |
32 | ### PPT 页面生成
33 | 
34 |
35 | ### PPT 预览导出
36 |
37 | **纯图片模式** - 直接导出 PPT 图片
38 | 
39 |
40 | **可编辑模式** - 去除文字仅保留背景,方便后期编辑
41 | 
42 |
43 | ## 快速开始
44 |
45 | 前往 [Releases](https://github.com/MoonWeSif/NextCreator/releases) 下载最新版本:
46 |
47 | - **macOS (Apple Silicon)**: `NextCreator_*_aarch64.dmg`
48 | - **macOS (Intel)**: `NextCreator_*_x64.dmg`
49 | - **Windows**: `NextCreator_*_x64-setup.exe`
50 |
51 | ### macOS 安装提示
52 |
53 | 由于应用未经 Apple 签名,首次打开可能会提示"无法验证开发者"。请在终端执行以下命令解决:
54 |
55 | ```bash
56 | xattr -rc "/Applications/NextCreator.app"
57 | codesign --force --deep --sign - "/Applications/NextCreator.app"
58 | ```
59 |
60 | ## 使用流程
61 |
62 | 1. **配置供应商** - 点击右上角「供应商管理」,添加 API 供应商(如 OpenAI、Google Gemini 等)
63 | 2. **分配供应商** - 在供应商管理中为不同节点类型(图片生成、视频生成、LLM 等)指定默认供应商
64 | 3. **创建工作流** - 从左侧节点面板拖拽节点到画布,连接节点构建工作流
65 | 4. **运行生成** - 填写输入内容,点击节点的生成按钮即可
66 |
67 | ## 本地开发
68 |
69 | ```bash
70 | # 安装依赖
71 | bun install
72 |
73 | # 开发模式
74 | bun run tauri dev
75 |
76 | # 构建应用
77 | bun run tauri build
78 | ```
79 |
80 | ## 可选:OCR + Inpaint 服务
81 |
82 | 如需使用 **PPT 可编辑导出**功能(去除文字仅保留背景),需要 OCR 和 Inpaint 服务。
83 |
84 | ### 方式一:使用公益服务
85 |
86 | 项目提供公益服务,可直接在设置中配置使用:
87 |
88 | | 服务 | 地址 | 用途 |
89 | |------|------|------|
90 | | EasyOCR | http://152.67.202.21:8866 | 文字检测识别 |
91 | | IOPaint | http://152.67.202.21:8877 | AI 背景修复 |
92 |
93 | > ⚠️ **注意**:公益服务受限于服务器性能,处理速度较慢,且不保障可用性。建议本地部署以获得更好体验。
94 |
95 | ### 方式二:本地部署(推荐)
96 |
97 | ```bash
98 | cd docker
99 | docker-compose up -d
100 | ```
101 |
102 | | 服务 | 地址 | 用途 |
103 | |------|------|------|
104 | | EasyOCR | http://127.0.0.1:8866 | 文字检测识别 |
105 | | IOPaint | http://127.0.0.1:8080 | AI 背景修复 |
106 |
107 | > 首次启动需下载模型,约 3-5 分钟
108 |
109 | ## 技术栈
110 |
111 | | 层级 | 技术 |
112 | |------|------|
113 | | 前端 | React 19 + TypeScript + Tailwind CSS + daisyUI |
114 | | 后端 | Tauri 2 (Rust) |
115 | | 状态 | Zustand + IndexedDB |
116 | | 节点 | @xyflow/react |
117 |
118 | ## 许可证
119 |
120 | 本项目基于 [GNU Affero General Public License v3](https://www.gnu.org/licenses/agpl-3.0.html) 发行,详细条款请参阅仓库根目录的 `LICENSE` 文件。
121 | IT
122 |
--------------------------------------------------------------------------------
/docker/ocr_server.py:
--------------------------------------------------------------------------------
1 | """
2 | EasyOCR HTTP 服务
3 | 提供 OCR 文字检测和识别 API
4 | 兼容原 PaddleOCR API 格式
5 | """
6 |
7 | from flask import Flask, request, jsonify
8 | import easyocr
9 | import base64
10 | import tempfile
11 | import os
12 |
13 | app = Flask(__name__)
14 |
15 | # 初始化 EasyOCR(首次运行会下载模型)
16 | print("正在初始化 EasyOCR...")
17 | reader = easyocr.Reader(
18 | ['ch_sim', 'en'], # 支持简体中文和英文
19 | gpu=False, # CPU 模式
20 | )
21 | print("EasyOCR 初始化完成")
22 |
23 |
24 | @app.route('/predict/ocr', methods=['POST'])
25 | def predict():
26 | """
27 | OCR 识别接口(兼容原 PaddleOCR API 格式)
28 |
29 | 请求格式:
30 | {
31 | "images": ["base64_encoded_image", ...]
32 | }
33 |
34 | 响应格式:
35 | {
36 | "status": "000",
37 | "results": [{
38 | "dt_polys": [[[x1,y1], [x2,y2], [x3,y3], [x4,y4]], ...],
39 | "rec_texts": ["识别的文字", ...],
40 | "rec_scores": [0.99, ...]
41 | }]
42 | }
43 | """
44 | try:
45 | data = request.json
46 | images = data.get('images', [])
47 |
48 | if not images:
49 | return jsonify({
50 | 'status': 'error',
51 | 'msg': 'No images provided'
52 | }), 400
53 |
54 | results = []
55 |
56 | for img_b64 in images:
57 | # 解码 base64 并保存为临时文件
58 | img_data = base64.b64decode(img_b64)
59 |
60 | with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as f:
61 | f.write(img_data)
62 | temp_path = f.name
63 |
64 | try:
65 | # EasyOCR 识别
66 | # 返回格式: [([[x1,y1], [x2,y2], [x3,y3], [x4,y4]], "文字", 置信度), ...]
67 | ocr_result = reader.readtext(temp_path)
68 | finally:
69 | # 清理临时文件
70 | os.remove(temp_path)
71 |
72 | # 转换为兼容 PaddleOCR 的格式
73 | dt_polys = []
74 | rec_texts = []
75 | rec_scores = []
76 |
77 | for item in ocr_result:
78 | bbox, text, confidence = item
79 | # bbox 是四个点的坐标 [[x1,y1], [x2,y2], [x3,y3], [x4,y4]]
80 | # 需要将 numpy 类型转换为 Python 原生类型
81 | bbox_list = [[int(p[0]), int(p[1])] for p in bbox]
82 | dt_polys.append(bbox_list)
83 | rec_texts.append(str(text))
84 | rec_scores.append(float(confidence))
85 |
86 | results.append({
87 | 'dt_polys': dt_polys,
88 | 'rec_texts': rec_texts,
89 | 'rec_scores': rec_scores
90 | })
91 |
92 | return jsonify({
93 | 'status': '000',
94 | 'results': results
95 | })
96 |
97 | except Exception as e:
98 | import traceback
99 | traceback.print_exc()
100 | return jsonify({
101 | 'status': 'error',
102 | 'msg': str(e)
103 | }), 500
104 |
105 |
106 | @app.route('/', methods=['GET'])
107 | def health():
108 | """健康检查接口"""
109 | return jsonify({
110 | 'status': 'ok',
111 | 'service': 'EasyOCR',
112 | 'languages': ['ch_sim', 'en']
113 | })
114 |
115 |
116 | if __name__ == '__main__':
117 | print('=' * 50)
118 | print('EasyOCR 服务启动中...')
119 | print('端口: 8866')
120 | print('API: POST /predict/ocr')
121 | print('支持语言: 简体中文, 英文')
122 | print('=' * 50)
123 | app.run(host='0.0.0.0', port=8866, threaded=True)
124 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTAssemblerNode/PagePreview.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { ChevronLeft, ChevronRight, Maximize2 } from "lucide-react";
3 | import type { PPTPageData } from "./types";
4 | import { ImagePreviewModal } from "@/components/ui/ImagePreviewModal";
5 |
6 | interface PagePreviewProps {
7 | pages: PPTPageData[];
8 | currentPage: number;
9 | onPageChange: (page: number) => void;
10 | }
11 |
12 | export function PagePreview({
13 | pages,
14 | currentPage,
15 | onPageChange,
16 | }: PagePreviewProps) {
17 | const [showFullPreview, setShowFullPreview] = useState(false);
18 |
19 | const page = pages[currentPage];
20 |
21 | if (!page) {
22 | return (
23 |
24 | 无页面数据
25 |
26 | );
27 | }
28 |
29 | // 优先使用缩略图预览,减少内存占用
30 | const previewImageUrl = page.thumbnail
31 | ? `data:image/jpeg;base64,${page.thumbnail}`
32 | : page.image
33 | ? `data:image/png;base64,${page.image}`
34 | : undefined;
35 |
36 | return (
37 | <>
38 |
39 | {/* 预览区域 - 使用缩略图显示 */}
40 |
page.image && setShowFullPreview(true)}
43 | >
44 |
45 | {previewImageUrl ? (
46 |

51 | ) : (
52 |
53 | 暂无图片
54 |
55 | )}
56 |
57 |
58 | {/* 悬浮放大按钮 */}
59 | {page.image && (
60 |
61 |
62 |
63 | )}
64 |
65 |
66 | {/* 页面信息 */}
67 |
68 | {page.heading}
69 |
70 |
71 | {/* 导航控制 */}
72 |
73 |
80 |
81 |
82 | 第 {currentPage + 1} 页 / 共 {pages.length} 页
83 |
84 |
85 |
92 |
93 |
94 |
95 | {/* 图片全屏预览 - 使用原图显示高质量图片 */}
96 | {showFullPreview && page.image && (
97 | setShowFullPreview(false)}
100 | />
101 | )}
102 | >
103 | );
104 | }
105 |
--------------------------------------------------------------------------------
/src/components/ui/LoadingIndicator.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * 加载指示器组件
3 | * 使用脉冲/点动画替代旋转动画,避免 GPU 合成层导致的字体模糊问题
4 | */
5 | import { memo } from "react";
6 |
7 | interface LoadingIndicatorProps {
8 | size?: "xs" | "sm" | "md" | "lg";
9 | variant?: "dots" | "pulse" | "bars";
10 | className?: string;
11 | }
12 |
13 | // 尺寸映射
14 | const sizeMap = {
15 | xs: { container: "w-3 h-3", dot: "w-1 h-1", bar: "w-0.5 h-2" },
16 | sm: { container: "w-4 h-4", dot: "w-1 h-1", bar: "w-0.5 h-3" },
17 | md: { container: "w-5 h-5", dot: "w-1.5 h-1.5", bar: "w-1 h-4" },
18 | lg: { container: "w-6 h-6", dot: "w-2 h-2", bar: "w-1 h-5" },
19 | };
20 |
21 | /**
22 | * 点状加载动画 - 三个点依次闪烁
23 | */
24 | const DotsLoader = memo(({ size, className }: { size: "xs" | "sm" | "md" | "lg"; className?: string }) => {
25 | const { container, dot } = sizeMap[size];
26 | return (
27 |
28 |
32 |
36 |
40 |
41 | );
42 | });
43 | DotsLoader.displayName = "DotsLoader";
44 |
45 | /**
46 | * 脉冲圆环动画 - 圆环脉冲扩散
47 | */
48 | const PulseLoader = memo(({ size, className }: { size: "xs" | "sm" | "md" | "lg"; className?: string }) => {
49 | const { container } = sizeMap[size];
50 | return (
51 |
52 |
53 |
54 |
55 | );
56 | });
57 | PulseLoader.displayName = "PulseLoader";
58 |
59 | /**
60 | * 条形加载动画 - 三条竖线依次变高
61 | */
62 | const BarsLoader = memo(({ size, className }: { size: "xs" | "sm" | "md" | "lg"; className?: string }) => {
63 | const { container, bar } = sizeMap[size];
64 | return (
65 |
66 |
70 |
74 |
78 |
79 | );
80 | });
81 | BarsLoader.displayName = "BarsLoader";
82 |
83 | /**
84 | * 加载指示器
85 | */
86 | export const LoadingIndicator = memo(({
87 | size = "sm",
88 | variant = "dots",
89 | className
90 | }: LoadingIndicatorProps) => {
91 | switch (variant) {
92 | case "pulse":
93 | return ;
94 | case "bars":
95 | return ;
96 | case "dots":
97 | default:
98 | return ;
99 | }
100 | });
101 | LoadingIndicator.displayName = "LoadingIndicator";
102 |
--------------------------------------------------------------------------------
/src/components/nodes/PromptNode.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState, useCallback } from "react";
2 | import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
3 | import { MessageSquare, Edit3 } from "lucide-react";
4 | import { useFlowStore } from "@/stores/flowStore";
5 | import { PromptEditorModal } from "@/components/ui/PromptEditorModal";
6 | import type { PromptNodeData } from "@/types";
7 |
8 | // 定义节点类型
9 | type PromptNode = Node;
10 |
11 | // 提示词输入节点
12 | // 使用 Modal 弹窗编辑,避免节点内滚动条导致画布模糊
13 | export const PromptNode = memo(({ id, data, selected }: NodeProps) => {
14 | const updateNodeData = useFlowStore((state) => state.updateNodeData);
15 | const [isModalOpen, setIsModalOpen] = useState(false);
16 |
17 | const prompt = data.prompt || "";
18 |
19 | // 保存提示词
20 | const handleSave = useCallback(
21 | (value: string) => {
22 | updateNodeData(id, { prompt: value });
23 | },
24 | [id, updateNodeData]
25 | );
26 |
27 | // 打开编辑弹窗
28 | const handleOpenModal = useCallback(() => {
29 | setIsModalOpen(true);
30 | }, []);
31 |
32 | // 关闭编辑弹窗
33 | const handleCloseModal = useCallback(() => {
34 | setIsModalOpen(false);
35 | }, []);
36 |
37 | return (
38 | <>
39 |
45 | {/* 节点头部 */}
46 |
47 |
48 |
49 | {data.label}
50 |
51 | {/* 编辑按钮 */}
52 |
59 |
60 |
61 | {/* 节点内容 - 预览区域,点击打开编辑弹窗 */}
62 |
66 |
74 | {prompt ? (
75 |
76 | {prompt}
77 |
78 | ) : (
79 |
点击编辑提示词...
80 | )}
81 |
82 |
83 | 点击编辑
84 |
85 |
86 |
87 | {/* 输出端口 - prompt 类型 */}
88 |
94 |
95 |
96 | {/* 编辑弹窗 */}
97 | {isModalOpen && (
98 |
104 | )}
105 | >
106 | );
107 | });
108 |
109 | PromptNode.displayName = "PromptNode";
110 |
--------------------------------------------------------------------------------
/src/components/ui/ContextMenu.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import { createPortal } from "react-dom";
3 |
4 | export interface ContextMenuItem {
5 | id: string;
6 | label: string;
7 | icon?: React.ReactNode;
8 | shortcut?: string;
9 | disabled?: boolean;
10 | danger?: boolean;
11 | divider?: boolean;
12 | onClick?: () => void;
13 | }
14 |
15 | interface ContextMenuProps {
16 | x: number;
17 | y: number;
18 | items: ContextMenuItem[];
19 | onClose: () => void;
20 | }
21 |
22 | export function ContextMenu({ x, y, items, onClose }: ContextMenuProps) {
23 | const menuRef = useRef(null);
24 |
25 | useEffect(() => {
26 | // 点击外部关闭菜单
27 | const handleClickOutside = (e: MouseEvent) => {
28 | if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
29 | onClose();
30 | }
31 | };
32 |
33 | // ESC 关闭菜单
34 | const handleKeyDown = (e: KeyboardEvent) => {
35 | if (e.key === "Escape") {
36 | onClose();
37 | }
38 | };
39 |
40 | // 滚动时关闭菜单
41 | const handleScroll = () => {
42 | onClose();
43 | };
44 |
45 | document.addEventListener("mousedown", handleClickOutside);
46 | document.addEventListener("keydown", handleKeyDown);
47 | document.addEventListener("scroll", handleScroll, true);
48 |
49 | return () => {
50 | document.removeEventListener("mousedown", handleClickOutside);
51 | document.removeEventListener("keydown", handleKeyDown);
52 | document.removeEventListener("scroll", handleScroll, true);
53 | };
54 | }, [onClose]);
55 |
56 | // 调整菜单位置,确保不超出视口
57 | useEffect(() => {
58 | if (menuRef.current) {
59 | const rect = menuRef.current.getBoundingClientRect();
60 | const viewportWidth = window.innerWidth;
61 | const viewportHeight = window.innerHeight;
62 |
63 | let adjustedX = x;
64 | let adjustedY = y;
65 |
66 | if (x + rect.width > viewportWidth) {
67 | adjustedX = viewportWidth - rect.width - 8;
68 | }
69 | if (y + rect.height > viewportHeight) {
70 | adjustedY = viewportHeight - rect.height - 8;
71 | }
72 |
73 | menuRef.current.style.left = `${adjustedX}px`;
74 | menuRef.current.style.top = `${adjustedY}px`;
75 | }
76 | }, [x, y]);
77 |
78 | return createPortal(
79 |
84 | {items.map((item, index) => {
85 | if (item.divider) {
86 | return
;
87 | }
88 |
89 | return (
90 |
116 | );
117 | })}
118 |
,
119 | document.body
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/src/services/fileStorageService.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 文件存储服务
3 | * 使用 Tauri 命令将图片存储为独立文件,而不是 base64 存储在 IndexedDB 中
4 | */
5 |
6 | import { invoke } from "@tauri-apps/api/core";
7 | import { convertFileSrc } from "@tauri-apps/api/core";
8 |
9 | // 图片信息类型
10 | export interface ImageInfo {
11 | id: string;
12 | filename: string;
13 | path: string;
14 | size: number;
15 | created_at: number;
16 | canvas_id?: string;
17 | node_id?: string;
18 | }
19 |
20 | // 存储统计信息类型
21 | export interface StorageStats {
22 | total_size: number;
23 | image_count: number;
24 | cache_size: number;
25 | images_by_canvas: CanvasImageStats[];
26 | }
27 |
28 | export interface CanvasImageStats {
29 | canvas_id: string;
30 | image_count: number;
31 | total_size: number;
32 | }
33 |
34 | /**
35 | * 保存图片到文件系统
36 | * @param base64Data - 图片的 base64 数据(不含 data:image/xxx;base64, 前缀)
37 | * @param canvasId - 可选的画布 ID,用于分组存储
38 | * @param nodeId - 可选的节点 ID
39 | * @returns 图片信息
40 | */
41 | export async function saveImage(
42 | base64Data: string,
43 | canvasId?: string,
44 | nodeId?: string
45 | ): Promise {
46 | return await invoke("save_image", {
47 | base64Data,
48 | canvasId,
49 | nodeId,
50 | });
51 | }
52 |
53 | /**
54 | * 读取图片文件(返回 base64)
55 | * @param path - 图片文件路径
56 | * @returns base64 编码的图片数据
57 | */
58 | export async function readImage(path: string): Promise {
59 | return await invoke("read_image", { path });
60 | }
61 |
62 | /**
63 | * 获取图片的可访问 URL
64 | * 使用 Tauri 的 convertFileSrc 将本地路径转换为 webview 可访问的 URL
65 | * @param path - 图片文件路径
66 | * @returns 可在 webview 中使用的 URL
67 | */
68 | export function getImageUrl(path: string): string {
69 | return convertFileSrc(path);
70 | }
71 |
72 | /**
73 | * 删除图片文件
74 | * @param path - 图片文件路径
75 | */
76 | export async function deleteImage(path: string): Promise {
77 | await invoke("delete_image", { path });
78 | }
79 |
80 | /**
81 | * 删除画布的所有图片
82 | * @param canvasId - 画布 ID
83 | * @returns 删除的总大小(字节)
84 | */
85 | export async function deleteCanvasImages(canvasId: string): Promise {
86 | return await invoke("delete_canvas_images", { canvasId });
87 | }
88 |
89 | /**
90 | * 获取存储统计信息
91 | * @returns 存储统计数据
92 | */
93 | export async function getStorageStats(): Promise {
94 | return await invoke("get_storage_stats");
95 | }
96 |
97 | /**
98 | * 清理缓存
99 | * @returns 清理的大小(字节)
100 | */
101 | export async function clearCache(): Promise {
102 | return await invoke("clear_cache");
103 | }
104 |
105 | /**
106 | * 清理所有图片
107 | * @returns 清理的大小(字节)
108 | */
109 | export async function clearAllImages(): Promise {
110 | return await invoke("clear_all_images");
111 | }
112 |
113 | /**
114 | * 获取应用存储路径
115 | * @returns 存储目录路径
116 | */
117 | export async function getStoragePath(): Promise {
118 | return await invoke("get_storage_path");
119 | }
120 |
121 | /**
122 | * 列出画布的所有图片
123 | * @param canvasId - 画布 ID
124 | * @returns 图片信息列表
125 | */
126 | export async function listCanvasImages(canvasId: string): Promise {
127 | return await invoke("list_canvas_images", { canvasId });
128 | }
129 |
130 | /**
131 | * 格式化文件大小
132 | * @param bytes - 字节数
133 | * @returns 格式化后的字符串(如 "1.5 MB")
134 | */
135 | export function formatFileSize(bytes: number): string {
136 | if (bytes === 0) return "0 B";
137 |
138 | const units = ["B", "KB", "MB", "GB", "TB"];
139 | const k = 1024;
140 | const i = Math.floor(Math.log(bytes) / Math.log(k));
141 |
142 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${units[i]}`;
143 | }
144 |
145 | /**
146 | * 检查是否在 Tauri 环境中运行
147 | * @returns 是否在 Tauri 环境中
148 | */
149 | export function isTauriEnvironment(): boolean {
150 | // Tauri 2.x 使用 __TAURI_INTERNALS__ 而不是 __TAURI__
151 | return (
152 | typeof window !== "undefined" &&
153 | ("__TAURI_INTERNALS__" in window || "__TAURI__" in window)
154 | );
155 | }
156 |
--------------------------------------------------------------------------------
/src/stores/settingsStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import { persist, createJSONStorage } from "zustand/middleware";
3 | import type { AppSettings, SettingsState, Provider, NodeProviderMapping } from "@/types";
4 | import { tauriStorage } from "@/utils/tauriStorage";
5 |
6 | // 默认设置
7 | const defaultSettings: AppSettings = {
8 | providers: [],
9 | nodeProviders: {},
10 | theme: "light",
11 | };
12 |
13 | interface SettingsStore extends SettingsState {
14 | // 基础设置
15 | updateSettings: (settings: Partial) => void;
16 | resetSettings: () => void;
17 | openSettings: () => void;
18 | closeSettings: () => void;
19 |
20 | // 供应商 CRUD
21 | addProvider: (provider: Omit) => string;
22 | updateProvider: (id: string, updates: Partial>) => void;
23 | removeProvider: (id: string) => void;
24 | getProviderById: (id: string) => Provider | undefined;
25 |
26 | // 节点供应商映射
27 | setNodeProvider: (nodeType: keyof NodeProviderMapping, providerId: string | undefined) => void;
28 | getNodeProvider: (nodeType: keyof NodeProviderMapping) => Provider | undefined;
29 |
30 | // 供应商面板状态
31 | isProviderPanelOpen: boolean;
32 | openProviderPanel: () => void;
33 | closeProviderPanel: () => void;
34 | }
35 |
36 | // 生成唯一 ID
37 | function generateId(): string {
38 | return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
39 | }
40 |
41 | export const useSettingsStore = create()(
42 | persist(
43 | (set, get) => ({
44 | settings: defaultSettings,
45 | isSettingsOpen: false,
46 | isProviderPanelOpen: false,
47 |
48 | updateSettings: (newSettings) =>
49 | set((state) => ({
50 | settings: { ...state.settings, ...newSettings },
51 | })),
52 |
53 | resetSettings: () =>
54 | set({ settings: defaultSettings }),
55 |
56 | openSettings: () =>
57 | set({ isSettingsOpen: true }),
58 |
59 | closeSettings: () =>
60 | set({ isSettingsOpen: false }),
61 |
62 | // 供应商 CRUD
63 | addProvider: (provider) => {
64 | const id = generateId();
65 | set((state) => ({
66 | settings: {
67 | ...state.settings,
68 | providers: [...state.settings.providers, { ...provider, id }],
69 | },
70 | }));
71 | return id;
72 | },
73 |
74 | updateProvider: (id, updates) =>
75 | set((state) => ({
76 | settings: {
77 | ...state.settings,
78 | providers: state.settings.providers.map((p) =>
79 | p.id === id ? { ...p, ...updates } : p
80 | ),
81 | },
82 | })),
83 |
84 | removeProvider: (id) =>
85 | set((state) => {
86 | // 移除供应商时,同时清除相关的节点映射
87 | const newNodeProviders = { ...state.settings.nodeProviders };
88 | for (const key of Object.keys(newNodeProviders) as (keyof NodeProviderMapping)[]) {
89 | if (newNodeProviders[key] === id) {
90 | delete newNodeProviders[key];
91 | }
92 | }
93 |
94 | return {
95 | settings: {
96 | ...state.settings,
97 | providers: state.settings.providers.filter((p) => p.id !== id),
98 | nodeProviders: newNodeProviders,
99 | },
100 | };
101 | }),
102 |
103 | getProviderById: (id) => {
104 | return get().settings.providers.find((p) => p.id === id);
105 | },
106 |
107 | // 节点供应商映射
108 | setNodeProvider: (nodeType, providerId) =>
109 | set((state) => ({
110 | settings: {
111 | ...state.settings,
112 | nodeProviders: {
113 | ...state.settings.nodeProviders,
114 | [nodeType]: providerId,
115 | },
116 | },
117 | })),
118 |
119 | getNodeProvider: (nodeType) => {
120 | const state = get();
121 | const providerId = state.settings.nodeProviders[nodeType];
122 | if (!providerId) return undefined;
123 | return state.settings.providers.find((p) => p.id === providerId);
124 | },
125 |
126 | // 供应商面板状态
127 | openProviderPanel: () =>
128 | set({ isProviderPanelOpen: true }),
129 |
130 | closeProviderPanel: () =>
131 | set({ isProviderPanelOpen: false }),
132 | }),
133 | {
134 | name: "next-creator-settings",
135 | storage: createJSONStorage(() => tauriStorage),
136 | partialize: (state) => ({ settings: state.settings }),
137 | }
138 | )
139 | );
140 |
--------------------------------------------------------------------------------
/src/services/updateService.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * GitHub 更新检测服务
3 | * 通过 GitHub API 获取最新 tag 来检测更新
4 | */
5 |
6 | // 从 package.json 动态获取版本号需要在编译时注入,这里使用常量
7 | // 在 vite.config.ts 中会通过 define 注入
8 | declare const __APP_VERSION__: string;
9 |
10 | // GitHub 仓库信息
11 | export const GITHUB_REPO = {
12 | owner: "MoonWeSif",
13 | repo: "NextCreator",
14 | url: "https://github.com/MoonWeSif/NextCreator",
15 | };
16 |
17 | // 项目信息
18 | export const PROJECT_INFO = {
19 | name: "NextCreator",
20 | description: "基于可视化节点的 AI 内容生成工作流工具",
21 | author: "MoonWeSif",
22 | license: "AGPL-3.0",
23 | };
24 |
25 | export interface UpdateInfo {
26 | hasUpdate: boolean;
27 | currentVersion: string;
28 | latestVersion: string;
29 | releaseUrl: string;
30 | releaseNotes?: string;
31 | publishedAt?: string;
32 | }
33 |
34 | export interface GitHubRelease {
35 | tag_name: string;
36 | html_url: string;
37 | body?: string;
38 | published_at?: string;
39 | prerelease: boolean;
40 | draft: boolean;
41 | }
42 |
43 | /**
44 | * 获取当前应用版本号
45 | */
46 | export function getCurrentVersion(): string {
47 | // 使用 Vite 注入的版本号,如果不存在则使用默认值
48 | return typeof __APP_VERSION__ !== "undefined" ? __APP_VERSION__ : "0.1.1";
49 | }
50 |
51 | /**
52 | * 比较版本号
53 | * @returns 正数表示 v1 > v2,负数表示 v1 < v2,0 表示相等
54 | */
55 | function compareVersions(v1: string, v2: string): number {
56 | // 移除版本号前的 v 前缀
57 | const normalize = (v: string) => v.replace(/^v/, "");
58 | const parts1 = normalize(v1).split(".").map(Number);
59 | const parts2 = normalize(v2).split(".").map(Number);
60 |
61 | const maxLen = Math.max(parts1.length, parts2.length);
62 |
63 | for (let i = 0; i < maxLen; i++) {
64 | const p1 = parts1[i] || 0;
65 | const p2 = parts2[i] || 0;
66 | if (p1 !== p2) {
67 | return p1 - p2;
68 | }
69 | }
70 |
71 | return 0;
72 | }
73 |
74 | /**
75 | * 从 GitHub API 获取最新的 release
76 | */
77 | async function fetchLatestRelease(): Promise {
78 | const url = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/releases/latest`;
79 |
80 | try {
81 | const response = await fetch(url, {
82 | headers: {
83 | Accept: "application/vnd.github.v3+json",
84 | },
85 | });
86 |
87 | if (response.status === 404) {
88 | // 没有 release,尝试获取 tags
89 | return null;
90 | }
91 |
92 | if (!response.ok) {
93 | throw new Error(`GitHub API 请求失败: ${response.status}`);
94 | }
95 |
96 | return await response.json();
97 | } catch (error) {
98 | console.error("获取最新 release 失败:", error);
99 | return null;
100 | }
101 | }
102 |
103 | /**
104 | * 从 GitHub API 获取最新的 tag
105 | */
106 | async function fetchLatestTag(): Promise {
107 | const url = `https://api.github.com/repos/${GITHUB_REPO.owner}/${GITHUB_REPO.repo}/tags`;
108 |
109 | try {
110 | const response = await fetch(url, {
111 | headers: {
112 | Accept: "application/vnd.github.v3+json",
113 | },
114 | });
115 |
116 | if (!response.ok) {
117 | throw new Error(`GitHub API 请求失败: ${response.status}`);
118 | }
119 |
120 | const tags: { name: string }[] = await response.json();
121 |
122 | if (tags.length === 0) {
123 | return null;
124 | }
125 |
126 | // 返回第一个 tag(最新的)
127 | return tags[0].name;
128 | } catch (error) {
129 | console.error("获取最新 tag 失败:", error);
130 | return null;
131 | }
132 | }
133 |
134 | /**
135 | * 检测更新
136 | */
137 | export async function checkForUpdates(): Promise {
138 | const currentVersion = getCurrentVersion();
139 |
140 | // 首先尝试获取 release 信息
141 | const release = await fetchLatestRelease();
142 |
143 | if (release && !release.draft && !release.prerelease) {
144 | const latestVersion = release.tag_name;
145 | const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
146 |
147 | return {
148 | hasUpdate,
149 | currentVersion,
150 | latestVersion: latestVersion.replace(/^v/, ""),
151 | releaseUrl: release.html_url,
152 | releaseNotes: release.body,
153 | publishedAt: release.published_at,
154 | };
155 | }
156 |
157 | // 如果没有 release,尝试获取 tag
158 | const latestTag = await fetchLatestTag();
159 |
160 | if (latestTag) {
161 | const hasUpdate = compareVersions(latestTag, currentVersion) > 0;
162 |
163 | return {
164 | hasUpdate,
165 | currentVersion,
166 | latestVersion: latestTag.replace(/^v/, ""),
167 | releaseUrl: `${GITHUB_REPO.url}/releases/tag/${latestTag}`,
168 | };
169 | }
170 |
171 | // 没有找到任何版本信息
172 | return {
173 | hasUpdate: false,
174 | currentVersion,
175 | latestVersion: currentVersion,
176 | releaseUrl: `${GITHUB_REPO.url}/releases`,
177 | };
178 | }
179 |
--------------------------------------------------------------------------------
/src/components/ui/PromptEditorModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect, useRef } from "react";
2 | import { createPortal } from "react-dom";
3 | import { X, MessageSquare, Check } from "lucide-react";
4 |
5 | interface PromptEditorModalProps {
6 | initialValue: string;
7 | onSave: (value: string) => void;
8 | onClose: () => void;
9 | title?: string;
10 | }
11 |
12 | // 提示词编辑弹窗组件
13 | // 使用 Portal 渲染到 body,避免被节点的 transform 影响导致画布模糊
14 | export function PromptEditorModal({
15 | initialValue,
16 | onSave,
17 | onClose,
18 | title = "编辑提示词",
19 | }: PromptEditorModalProps) {
20 | const [value, setValue] = useState(initialValue);
21 | const [isVisible, setIsVisible] = useState(false);
22 | const [isClosing, setIsClosing] = useState(false);
23 | const textareaRef = useRef(null);
24 | const isComposingRef = useRef(false);
25 |
26 | // 进入动画
27 | useEffect(() => {
28 | requestAnimationFrame(() => setIsVisible(true));
29 | // 聚焦到文本框末尾
30 | if (textareaRef.current) {
31 | textareaRef.current.focus();
32 | textareaRef.current.setSelectionRange(value.length, value.length);
33 | }
34 | }, []);
35 |
36 | // 关闭时先播放退出动画
37 | const handleClose = useCallback(() => {
38 | setIsClosing(true);
39 | setIsVisible(false);
40 | setTimeout(onClose, 200);
41 | }, [onClose]);
42 |
43 | // 保存并关闭
44 | const handleSave = useCallback(() => {
45 | onSave(value);
46 | handleClose();
47 | }, [value, onSave, handleClose]);
48 |
49 | // ESC 键关闭,Ctrl/Cmd + Enter 保存
50 | useEffect(() => {
51 | const handleKeyDown = (e: KeyboardEvent) => {
52 | if (e.key === "Escape") {
53 | handleClose();
54 | } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
55 | e.preventDefault();
56 | handleSave();
57 | }
58 | };
59 | document.addEventListener("keydown", handleKeyDown);
60 | return () => document.removeEventListener("keydown", handleKeyDown);
61 | }, [handleClose, handleSave]);
62 |
63 | return createPortal(
64 |
72 | {/* Modal 内容 */}
73 |
e.stopPropagation()}
83 | >
84 | {/* 头部 */}
85 |
86 |
87 |
88 | {title}
89 |
90 |
96 |
97 |
98 | {/* 编辑区域 */}
99 |
100 |
114 |
115 | {/* 底部操作栏 */}
116 |
117 |
118 | 按 ESC 取消 · Ctrl/Cmd + Enter 保存
119 |
120 |
121 |
124 |
128 |
129 |
130 |
131 |
,
132 | document.body
133 | );
134 | }
135 |
--------------------------------------------------------------------------------
/src/components/panels/NodePanel.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from "react";
2 | import {
3 | ChevronDown,
4 | ChevronRight,
5 | Search,
6 | GripVertical,
7 | } from "lucide-react";
8 | import { nodeCategories, nodeIconMap, nodeIconColors } from "@/config/nodeConfig";
9 |
10 | interface NodePanelProps {
11 | onDragStart: (event: React.DragEvent, nodeType: string, defaultData: Record) => void;
12 | }
13 |
14 | export function NodePanel({ onDragStart }: NodePanelProps) {
15 | const [searchQuery, setSearchQuery] = useState("");
16 | const [expandedCategories, setExpandedCategories] = useState>(
17 | new Set(nodeCategories.map((c) => c.id))
18 | );
19 |
20 | const toggleCategory = useCallback((categoryId: string) => {
21 | setExpandedCategories((prev) => {
22 | const next = new Set(prev);
23 | if (next.has(categoryId)) {
24 | next.delete(categoryId);
25 | } else {
26 | next.add(categoryId);
27 | }
28 | return next;
29 | });
30 | }, []);
31 |
32 | // 过滤节点
33 | const filteredCategories = nodeCategories
34 | .map((category) => ({
35 | ...category,
36 | nodes: category.nodes.filter(
37 | (node) =>
38 | node.label.toLowerCase().includes(searchQuery.toLowerCase()) ||
39 | node.description.toLowerCase().includes(searchQuery.toLowerCase())
40 | ),
41 | }))
42 | .filter((category) => category.nodes.length > 0);
43 |
44 | return (
45 |
46 | {/* 头部 */}
47 |
48 |
节点库
49 |
50 |
51 | setSearchQuery(e.target.value)}
57 | />
58 |
59 |
60 |
61 | {/* 节点列表 */}
62 |
63 | {filteredCategories.map((category) => (
64 |
65 | {/* 分类标题 */}
66 |
80 |
81 | {/* 节点项 */}
82 | {expandedCategories.has(category.id) && (
83 |
84 | {category.nodes.map((node) => {
85 | const IconComponent = nodeIconMap[node.icon];
86 | const iconColorClass = nodeIconColors[node.icon] || "";
87 | return (
88 |
onDragStart(e, node.type, node.defaultData)}
93 | >
94 |
95 |
96 | {IconComponent && }
97 |
98 |
99 |
{node.label}
100 |
101 | {node.description}
102 |
103 |
104 |
105 | );
106 | })}
107 |
108 | )}
109 |
110 | ))}
111 |
112 |
113 | {/* 底部提示 */}
114 |
115 |
116 | 拖拽节点到画布中使用
117 |
118 |
119 |
120 | );
121 | }
122 |
--------------------------------------------------------------------------------
/src/components/panels/KeyboardShortcutsPanel.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { X, Keyboard } from "lucide-react";
3 |
4 | interface ShortcutGroup {
5 | title: string;
6 | shortcuts: { keys: string[]; description: string }[];
7 | }
8 |
9 | interface KeyboardShortcutsPanelProps {
10 | isOpen: boolean;
11 | onClose: () => void;
12 | }
13 |
14 | export function KeyboardShortcutsPanel({ isOpen, onClose }: KeyboardShortcutsPanelProps) {
15 | const isMac = typeof navigator !== "undefined" && navigator.platform.toUpperCase().indexOf("MAC") >= 0;
16 | const cmdKey = isMac ? "⌘" : "Ctrl";
17 |
18 | const shortcutGroups: ShortcutGroup[] = [
19 | {
20 | title: "基本操作",
21 | shortcuts: [
22 | { keys: ["Delete", "Backspace"], description: "删除选中的节点或连线" },
23 | { keys: [`${cmdKey}`, "C"], description: "复制选中的节点" },
24 | { keys: [`${cmdKey}`, "V"], description: "粘贴节点" },
25 | { keys: [`${cmdKey}`, "D"], description: "创建选中节点的副本" },
26 | { keys: [`${cmdKey}`, "A"], description: "全选所有节点" },
27 | { keys: ["Esc"], description: "取消选择" },
28 | ],
29 | },
30 | {
31 | title: "撤销与重做",
32 | shortcuts: [
33 | { keys: [`${cmdKey}`, "Z"], description: "撤销上一步操作" },
34 | { keys: [`${cmdKey}`, "Shift", "Z"], description: "重做操作" },
35 | ],
36 | },
37 | {
38 | title: "布局与整理",
39 | shortcuts: [
40 | { keys: [`${cmdKey}`, "O"], description: "自动整理节点布局" },
41 | ],
42 | },
43 | {
44 | title: "多选操作",
45 | shortcuts: [
46 | { keys: [`${cmdKey}`, "单击"], description: "添加/移除节点到选区" },
47 | { keys: ["Shift", "单击"], description: "添加/移除节点到选区" },
48 | { keys: ["拖拽框选"], description: "框选多个节点" },
49 | ],
50 | },
51 | {
52 | title: "画布导航",
53 | shortcuts: [
54 | { keys: ["鼠标滚轮"], description: "缩放画布" },
55 | { keys: ["鼠标中键拖拽"], description: "平移画布" },
56 | { keys: ["右键拖拽"], description: "平移画布" },
57 | ],
58 | },
59 | ];
60 |
61 | // ESC 关闭面板
62 | useEffect(() => {
63 | const handleKeyDown = (e: KeyboardEvent) => {
64 | if (e.key === "Escape" && isOpen) {
65 | onClose();
66 | }
67 | };
68 |
69 | document.addEventListener("keydown", handleKeyDown);
70 | return () => document.removeEventListener("keydown", handleKeyDown);
71 | }, [isOpen, onClose]);
72 |
73 | if (!isOpen) {
74 | return null;
75 | }
76 |
77 | return (
78 |
79 |
80 | {/* 头部 */}
81 |
82 |
83 |
84 |
键盘快捷键
85 |
86 |
92 |
93 |
94 | {/* 内容 */}
95 |
96 |
97 | {shortcutGroups.map((group) => (
98 |
99 |
100 | {group.title}
101 |
102 |
103 | {group.shortcuts.map((shortcut, index) => (
104 |
108 |
{shortcut.description}
109 |
110 | {shortcut.keys.map((key, keyIndex) => (
111 |
112 |
113 | {key}
114 |
115 | {keyIndex < shortcut.keys.length - 1 && (
116 | +
117 | )}
118 |
119 | ))}
120 |
121 |
122 | ))}
123 |
124 |
125 | ))}
126 |
127 |
128 | {/* 提示 */}
129 |
130 |
131 | 按 Esc 或 ? 关闭此面板
132 |
133 |
134 |
135 |
136 |
137 | );
138 | }
139 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useRef, useState } from "react";
2 | import { ReactFlowProvider } from "@xyflow/react";
3 |
4 | import { Toolbar } from "@/components/Toolbar";
5 | import { FlowCanvas } from "@/components/FlowCanvas";
6 | import { Sidebar } from "@/components/Sidebar";
7 | import { SettingsPanel, KeyboardShortcutsPanel } from "@/components/panels";
8 | import { ProviderPanel } from "@/components/panels/ProviderPanel";
9 | import { StorageManagementModal } from "@/components/ui/StorageManagementModal";
10 | import { ToastContainer } from "@/components/ui/Toast";
11 | import { useCanvasStore } from "@/stores/canvasStore";
12 | import { useFlowStore } from "@/stores/flowStore";
13 | import { useSettingsStore } from "@/stores/settingsStore";
14 |
15 | import "@/index.css";
16 |
17 | function App() {
18 | const { activeCanvasId, getActiveCanvas, createCanvas, updateCanvasData, canvases } = useCanvasStore();
19 | const { nodes, edges, setNodes, setEdges } = useFlowStore();
20 | const theme = useSettingsStore((state) => state.settings.theme);
21 |
22 | // 帮助面板状态
23 | const [isHelpOpen, setIsHelpOpen] = useState(false);
24 |
25 | // 用于追踪是否正在切换画布,避免循环更新
26 | const isLoadingCanvasRef = useRef(false);
27 | const prevCanvasIdRef = useRef(null);
28 |
29 | // 应用主题到 HTML 元素
30 | useEffect(() => {
31 | const applyTheme = (themeName: string) => {
32 | if (themeName === "system") {
33 | // 跟随系统主题
34 | const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
35 | document.documentElement.setAttribute("data-theme", prefersDark ? "dark" : "light");
36 | } else {
37 | document.documentElement.setAttribute("data-theme", themeName);
38 | }
39 | };
40 |
41 | applyTheme(theme);
42 |
43 | // 如果是跟随系统,监听系统主题变化
44 | if (theme === "system") {
45 | const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
46 | const handleChange = (e: MediaQueryListEvent) => {
47 | document.documentElement.setAttribute("data-theme", e.matches ? "dark" : "light");
48 | };
49 | mediaQuery.addEventListener("change", handleChange);
50 | return () => mediaQuery.removeEventListener("change", handleChange);
51 | }
52 | }, [theme]);
53 |
54 | // 初始化:如果没有画布,创建一个默认画布
55 | useEffect(() => {
56 | if (canvases.length === 0) {
57 | createCanvas("默认画布");
58 | }
59 | }, [canvases.length, createCanvas]);
60 |
61 | // 切换画布时加载画布数据
62 | useEffect(() => {
63 | if (activeCanvasId && activeCanvasId !== prevCanvasIdRef.current) {
64 | isLoadingCanvasRef.current = true;
65 | prevCanvasIdRef.current = activeCanvasId;
66 |
67 | const canvas = getActiveCanvas();
68 | if (canvas) {
69 | setNodes(canvas.nodes);
70 | setEdges(canvas.edges);
71 | }
72 |
73 | // 延迟重置标志,确保数据加载完成
74 | requestAnimationFrame(() => {
75 | isLoadingCanvasRef.current = false;
76 | });
77 | }
78 | }, [activeCanvasId, getActiveCanvas, setNodes, setEdges]);
79 |
80 | // 同步节点和边的变化到画布存储(防抖处理)
81 | useEffect(() => {
82 | // 如果正在加载画布数据,不进行同步
83 | if (isLoadingCanvasRef.current || !activeCanvasId) return;
84 |
85 | // 使用防抖来减少频繁更新
86 | // 将间隔拉长到 800ms,避免拖动時頻繁寫入持久化存儲
87 | const timer = setTimeout(() => {
88 | updateCanvasData(nodes, edges);
89 | }, 800);
90 |
91 | return () => clearTimeout(timer);
92 | }, [nodes, edges, activeCanvasId, updateCanvasData]);
93 |
94 | // 监听 ? 键打开帮助面板
95 | useEffect(() => {
96 | const handleKeyDown = (e: KeyboardEvent) => {
97 | if (
98 | e.target instanceof HTMLInputElement ||
99 | e.target instanceof HTMLTextAreaElement
100 | ) {
101 | return;
102 | }
103 |
104 | if (e.key === "?" || (e.key === "/" && e.shiftKey)) {
105 | e.preventDefault();
106 | setIsHelpOpen((prev) => !prev);
107 | }
108 | };
109 |
110 | document.addEventListener("keydown", handleKeyDown);
111 | return () => document.removeEventListener("keydown", handleKeyDown);
112 | }, []);
113 |
114 | // 拖拽开始处理
115 | const onDragStart = useCallback(
116 | (
117 | event: React.DragEvent,
118 | nodeType: string,
119 | defaultData: Record
120 | ) => {
121 | event.dataTransfer.setData("application/reactflow/type", nodeType);
122 | event.dataTransfer.setData(
123 | "application/reactflow/data",
124 | JSON.stringify(defaultData)
125 | );
126 | event.dataTransfer.effectAllowed = "move";
127 | },
128 | []
129 | );
130 |
131 | return (
132 |
133 |
134 | {/* 顶部工具栏 */}
135 |
setIsHelpOpen(true)} />
136 |
137 | {/* 主体内容 */}
138 |
139 | {/* 左侧导航栏(包含画布列表和节点库) */}
140 |
141 |
142 | {/* 右侧画布区域 */}
143 |
144 |
145 |
146 | {/* 设置面板 */}
147 |
148 |
149 | {/* 供应商管理面板 */}
150 |
151 |
152 | {/* 快捷键帮助面板 */}
153 | setIsHelpOpen(false)} />
154 |
155 | {/* 存储管理弹窗 */}
156 |
157 |
158 | {/* Toast 通知容器 */}
159 |
160 |
161 |
162 | );
163 | }
164 |
165 | export default App;
166 |
--------------------------------------------------------------------------------
/src/config/nodeConfig.ts:
--------------------------------------------------------------------------------
1 | import {
2 | MessageSquare,
3 | Sparkles,
4 | Zap,
5 | ImagePlus,
6 | Video,
7 | FileText,
8 | Presentation,
9 | MessageSquareText,
10 | FileUp,
11 | } from "lucide-react";
12 | import type { NodeCategory } from "@/types";
13 |
14 | // 节点分类定义 - 统一配置
15 | export const nodeCategories: NodeCategory[] = [
16 | {
17 | id: "input",
18 | name: "输入",
19 | icon: "input",
20 | nodes: [
21 | {
22 | type: "promptNode",
23 | label: "提示词",
24 | description: "输入文本提示词用于图片生成",
25 | icon: "MessageSquare",
26 | defaultData: { label: "提示词", prompt: "" },
27 | outputs: ["prompt"],
28 | },
29 | {
30 | type: "imageInputNode",
31 | label: "图片输入",
32 | description: "上传图片用于图片编辑",
33 | icon: "ImagePlus",
34 | defaultData: { label: "图片输入" },
35 | outputs: ["image"],
36 | },
37 | {
38 | type: "fileUploadNode",
39 | label: "文件上传",
40 | description: "上传文件供 LLM 解析(支持图片/PDF/音频/视频)",
41 | icon: "FileUp",
42 | defaultData: { label: "文件上传" },
43 | outputs: ["file"],
44 | },
45 | ],
46 | },
47 | {
48 | id: "processing",
49 | name: "处理",
50 | icon: "processing",
51 | nodes: [
52 | {
53 | type: "imageGeneratorProNode",
54 | label: "NanoBanana Pro",
55 | description: "高质量生成,支持 4K 分辨率",
56 | icon: "Sparkles",
57 | defaultData: {
58 | label: "NanoBanana Pro",
59 | model: "gemini-3-pro-image-preview",
60 | aspectRatio: "1:1",
61 | imageSize: "1K",
62 | status: "idle",
63 | },
64 | inputs: ["prompt", "image"],
65 | outputs: ["image"],
66 | },
67 | {
68 | type: "imageGeneratorFastNode",
69 | label: "NanoBanana",
70 | description: "快速生成,适合批量任务",
71 | icon: "Zap",
72 | defaultData: {
73 | label: "NanoBanana",
74 | model: "gemini-2.5-flash-image",
75 | aspectRatio: "1:1",
76 | status: "idle",
77 | },
78 | inputs: ["prompt", "image"],
79 | outputs: ["image"],
80 | },
81 | {
82 | type: "llmContentNode",
83 | label: "LLM 内容生成",
84 | description: "大语言模型文本生成",
85 | icon: "MessageSquareText",
86 | defaultData: {
87 | label: "LLM 内容生成",
88 | model: "gemini-2.5-flash",
89 | systemPrompt: "",
90 | temperature: 0.7,
91 | maxTokens: 8192,
92 | status: "idle",
93 | },
94 | inputs: ["prompt", "file"],
95 | outputs: ["prompt"],
96 | },
97 | {
98 | type: "videoGeneratorNode",
99 | label: "视频生成 Sora",
100 | description: "使用 Sora 模型生成视频",
101 | icon: "Video",
102 | defaultData: {
103 | label: "视频生成",
104 | model: "sora-2",
105 | seconds: "10",
106 | size: "1280x720",
107 | status: "idle",
108 | },
109 | inputs: ["prompt", "image"],
110 | outputs: ["video"],
111 | },
112 | ],
113 | },
114 | {
115 | id: "ppt",
116 | name: "PPT 工作流",
117 | icon: "ppt",
118 | nodes: [
119 | {
120 | type: "pptContentNode",
121 | label: "PPT 内容生成",
122 | description: "生成 PPT 大纲和页面图片",
123 | icon: "FileText",
124 | defaultData: {
125 | label: "PPT 内容生成",
126 | activeTab: "config",
127 | outlineConfig: {
128 | pageCountRange: "8-12",
129 | detailLevel: "moderate",
130 | additionalNotes: "",
131 | },
132 | outlineModel: "gemini-3-pro-preview",
133 | imageModel: "gemini-3-pro-image-preview",
134 | outlineStatus: "idle",
135 | imageConfig: {
136 | aspectRatio: "16:9",
137 | imageSize: "2K",
138 | },
139 | visualStyleTemplate: "academic",
140 | firstPageIsTitlePage: true,
141 | pages: [],
142 | generationStatus: "idle",
143 | progress: { completed: 0, total: 0 },
144 | },
145 | inputs: ["prompt", "image", "file"],
146 | outputs: ["results"],
147 | },
148 | {
149 | type: "pptAssemblerNode",
150 | label: "PPT 组装",
151 | description: "预览并导出 PPTX 和讲稿",
152 | icon: "Presentation",
153 | defaultData: {
154 | label: "PPT 组装",
155 | aspectRatio: "16:9",
156 | pages: [],
157 | status: "idle",
158 | exportMode: "image",
159 | ocrApiUrl: "http://127.0.0.1:8866",
160 | inpaintApiUrl: "http://127.0.0.1:8080",
161 | },
162 | inputs: ["results"],
163 | outputs: [],
164 | },
165 | ],
166 | },
167 | ];
168 |
169 | // 图标映射
170 | export const nodeIconMap: Record> = {
171 | MessageSquare,
172 | Sparkles,
173 | Zap,
174 | ImagePlus,
175 | Video,
176 | FileText,
177 | Presentation,
178 | MessageSquareText,
179 | FileUp,
180 | };
181 |
182 | // 图标颜色映射
183 | export const nodeIconColors: Record = {
184 | MessageSquare: "bg-blue-500/10 text-blue-500",
185 | Sparkles: "bg-purple-500/10 text-purple-500",
186 | Zap: "bg-amber-500/10 text-amber-500",
187 | ImagePlus: "bg-green-500/10 text-green-500",
188 | Video: "bg-cyan-500/10 text-cyan-500",
189 | FileText: "bg-indigo-500/10 text-indigo-500",
190 | Presentation: "bg-emerald-500/10 text-emerald-500",
191 | MessageSquareText: "bg-teal-500/10 text-teal-500",
192 | FileUp: "bg-orange-500/10 text-orange-500",
193 | };
194 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTContentNode/ImageSelectorModal.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState, useCallback } from "react";
2 | import { createPortal } from "react-dom";
3 | import { X, Check, Image } from "lucide-react";
4 | import type { ConnectedImageInfo } from "./types";
5 |
6 | interface ImageSelectorModalProps {
7 | images: ConnectedImageInfo[];
8 | selectedIds: string[];
9 | excludeIds?: string[]; // 排除的图片 ID(如基底图)
10 | onConfirm: (selectedIds: string[]) => void;
11 | onClose: () => void;
12 | }
13 |
14 | // 图片选择器弹窗 - 网格布局,支持多选
15 | export const ImageSelectorModal = memo(({
16 | images,
17 | selectedIds,
18 | excludeIds = [],
19 | onConfirm,
20 | onClose,
21 | }: ImageSelectorModalProps) => {
22 | const [isVisible, setIsVisible] = useState(true);
23 | const [localSelectedIds, setLocalSelectedIds] = useState(selectedIds);
24 |
25 | // 可选的图片(排除指定的图片)
26 | const availableImages = images.filter(img => !excludeIds.includes(img.id));
27 |
28 | // 切换选中状态
29 | const toggleSelection = useCallback((id: string) => {
30 | setLocalSelectedIds(prev =>
31 | prev.includes(id)
32 | ? prev.filter(i => i !== id)
33 | : [...prev, id]
34 | );
35 | }, []);
36 |
37 | // 关闭弹窗(带动画)
38 | const handleClose = useCallback(() => {
39 | setIsVisible(false);
40 | setTimeout(onClose, 200);
41 | }, [onClose]);
42 |
43 | // 确认选择
44 | const handleConfirm = useCallback(() => {
45 | onConfirm(localSelectedIds);
46 | handleClose();
47 | }, [localSelectedIds, onConfirm, handleClose]);
48 |
49 | return createPortal(
50 |
58 |
e.stopPropagation()}
68 | >
69 | {/* 头部 */}
70 |
71 |
72 |
73 | 选择参考图片
74 |
75 | (可选 {availableImages.length} 张)
76 |
77 |
78 |
84 |
85 |
86 | {/* 内容 */}
87 |
88 | {availableImages.length === 0 ? (
89 |
90 |
91 |
没有可用的参考图片
92 |
请先连接图片输入节点
93 |
94 | ) : (
95 |
96 | {availableImages.map((img) => {
97 | const isSelected = localSelectedIds.includes(img.id);
98 | return (
99 |
toggleSelection(img.id)}
109 | >
110 | {/* 图片 */}
111 |
112 |

117 |
118 |
119 | {/* 选中标记 */}
120 | {isSelected && (
121 |
122 |
123 |
124 | )}
125 |
126 | {/* 文件名 */}
127 |
128 |
129 | {img.fileName || `图片-${img.id.slice(0, 4)}`}
130 |
131 |
132 |
133 | );
134 | })}
135 |
136 | )}
137 |
138 |
139 | {/* 底部 */}
140 |
141 |
142 | 已选择 {localSelectedIds.length} 张
143 |
144 |
145 |
148 |
151 |
152 |
153 |
154 |
,
155 | document.body
156 | );
157 | });
158 |
159 | ImageSelectorModal.displayName = "ImageSelectorModal";
160 |
--------------------------------------------------------------------------------
/src/components/ui/Select.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useRef, useEffect, useCallback } from "react";
2 | import { createPortal } from "react-dom";
3 | import { ChevronDown, Check } from "lucide-react";
4 |
5 | interface SelectOption {
6 | value: string;
7 | label: string;
8 | }
9 |
10 | interface SelectProps {
11 | value: string;
12 | options: SelectOption[];
13 | onChange: (value: string) => void;
14 | placeholder?: string;
15 | className?: string;
16 | /** 是否通过 Portal 渲染到 body,默认 true;在部分桌面环境下可关闭以避免合成问题 */
17 | usePortal?: boolean;
18 | }
19 |
20 | export function Select({
21 | value,
22 | options,
23 | onChange,
24 | placeholder = "请选择",
25 | className = "",
26 | usePortal = true,
27 | }: SelectProps) {
28 | const [isOpen, setIsOpen] = useState(false);
29 | const [isVisible, setIsVisible] = useState(false);
30 | const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
31 | const containerRef = useRef(null);
32 | const buttonRef = useRef(null);
33 |
34 | const selectedOption = options.find((opt) => opt.value === value);
35 |
36 | // 计算下拉菜单位置
37 | const updateDropdownPosition = useCallback(() => {
38 | if (buttonRef.current) {
39 | const rect = buttonRef.current.getBoundingClientRect();
40 | setDropdownPosition({
41 | top: rect.bottom + 6, // 6px 间距
42 | left: rect.left,
43 | width: rect.width,
44 | });
45 | }
46 | }, []);
47 |
48 | // 打开下拉框
49 | const openDropdown = useCallback(() => {
50 | setIsOpen(true);
51 | updateDropdownPosition();
52 | requestAnimationFrame(() => setIsVisible(true));
53 | }, [updateDropdownPosition]);
54 |
55 | // 关闭下拉框(带动画)
56 | const closeDropdown = useCallback(() => {
57 | setIsVisible(false);
58 | setTimeout(() => setIsOpen(false), 150);
59 | }, []);
60 |
61 | // 点击外部关闭
62 | useEffect(() => {
63 | const handleClickOutside = (event: MouseEvent) => {
64 | if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
65 | closeDropdown();
66 | }
67 | };
68 |
69 | if (isOpen) {
70 | document.addEventListener("mousedown", handleClickOutside);
71 | }
72 | return () => {
73 | document.removeEventListener("mousedown", handleClickOutside);
74 | };
75 | }, [isOpen, closeDropdown]);
76 |
77 | // 滚动或缩放时关闭下拉框
78 | useEffect(() => {
79 | if (isOpen) {
80 | const handleScroll = () => closeDropdown();
81 | window.addEventListener("scroll", handleScroll, true);
82 | window.addEventListener("resize", handleScroll);
83 | return () => {
84 | window.removeEventListener("scroll", handleScroll, true);
85 | window.removeEventListener("resize", handleScroll);
86 | };
87 | }
88 | }, [isOpen, closeDropdown]);
89 |
90 | // 下拉菜单内容
91 | const dropdownContent = (
92 | e.stopPropagation()}
114 | >
115 | {options.map((option) => (
116 |
139 | ))}
140 |
141 | );
142 |
143 | return (
144 | e.stopPropagation()}
148 | onMouseDown={(e) => e.stopPropagation()}
149 | >
150 | {/* 触发按钮 */}
151 |
179 |
180 | {/* 下拉菜单 */}
181 | {isOpen && (
182 | usePortal
183 | ? createPortal(dropdownContent, document.body)
184 | : dropdownContent
185 | )}
186 |
187 | );
188 | }
189 |
--------------------------------------------------------------------------------
/src/types/index.ts:
--------------------------------------------------------------------------------
1 | import type { Node, Edge } from "@xyflow/react";
2 |
3 | // 模型类型(图片生成)- 支持自定义模型名称
4 | export type ModelType = string;
5 |
6 | // 视频模型类型
7 | export type VideoModelType = "sora-2" | "sora-2-pro";
8 |
9 | // 视频尺寸类型
10 | export type VideoSizeType = "720x1280" | "1280x720" | "1024x1792" | "1792x1024";
11 |
12 | // LLM 模型类型(支持自定义模型名称)
13 | export type LLMModelType = string;
14 |
15 | // 视频生成参数
16 | export interface VideoGenerationParams {
17 | prompt: string;
18 | model: VideoModelType;
19 | seconds?: "10" | "15" | "25"; // sora-2: 10/15, sora-2-pro: 10/15/25
20 | size?: VideoSizeType;
21 | inputImage?: string; // base64 编码的参考图片
22 | }
23 |
24 | // 视频任务状态响应
25 | export interface VideoTaskResponse {
26 | id: string;
27 | object: string;
28 | model: string;
29 | status: "queued" | "in_progress" | "completed" | "failed";
30 | progress: number;
31 | created_at: number;
32 | seconds: string;
33 | completed_at?: number;
34 | expires_at?: number;
35 | size?: string;
36 | error?: {
37 | code: string;
38 | message: string;
39 | };
40 | metadata?: Record;
41 | }
42 |
43 | // 视频生成响应
44 | export interface VideoGenerationResponse {
45 | taskId?: string;
46 | videoUrl?: string;
47 | videoData?: string; // base64 编码的视频数据
48 | status?: VideoTaskResponse["status"];
49 | progress?: number;
50 | error?: string;
51 | }
52 |
53 | // 图片生成参数
54 | export interface ImageGenerationParams {
55 | prompt: string;
56 | model: ModelType;
57 | aspectRatio?: "1:1" | "16:9" | "9:16" | "4:3" | "3:4" | "3:2" | "2:3" | "5:4" | "4:5" | "21:9";
58 | imageSize?: "1K" | "2K" | "4K";
59 | responseModalities?: ("TEXT" | "IMAGE")[];
60 | }
61 |
62 | // 图片编辑参数
63 | export interface ImageEditParams extends ImageGenerationParams {
64 | inputImages?: string[]; // base64 编码的图片数组(支持多图输入)
65 | }
66 |
67 | // API 响应
68 | export interface GenerationResponse {
69 | imageData?: string; // base64 编码的图片数据
70 | text?: string;
71 | error?: string;
72 | }
73 |
74 | // 节点数据类型 - 添加索引签名以满足 React Flow 的 Record 约束
75 | export interface PromptNodeData {
76 | [key: string]: unknown;
77 | label: string;
78 | prompt: string;
79 | }
80 |
81 | export interface ImageGeneratorNodeData {
82 | [key: string]: unknown;
83 | label: string;
84 | model: ModelType;
85 | aspectRatio: ImageGenerationParams["aspectRatio"];
86 | imageSize: ImageGenerationParams["imageSize"];
87 | status: "idle" | "loading" | "success" | "error";
88 | outputImage?: string; // 仍保留 base64 用于向后兼容
89 | outputImagePath?: string; // 新增:文件系统路径
90 | error?: string;
91 | }
92 |
93 | export interface ImageInputNodeData {
94 | [key: string]: unknown;
95 | label: string;
96 | imageData?: string;
97 | fileName?: string;
98 | imagePath?: string;
99 | }
100 |
101 | export interface TextOutputNodeData {
102 | [key: string]: unknown;
103 | label: string;
104 | text?: string;
105 | }
106 |
107 | export interface VideoGeneratorNodeData {
108 | [key: string]: unknown;
109 | label: string;
110 | model: VideoModelType;
111 | seconds: VideoGenerationParams["seconds"];
112 | size?: VideoSizeType;
113 | status: "idle" | "loading" | "success" | "error";
114 | taskId?: string;
115 | taskStage?: "queued" | "in_progress" | "completed" | "failed"; // 任务阶段
116 | progress?: number;
117 | outputVideo?: string; // 视频 URL
118 | error?: string;
119 | }
120 |
121 | // LLM 内容生成节点数据
122 | export interface LLMContentNodeData {
123 | [key: string]: unknown;
124 | label: string;
125 | model: LLMModelType;
126 | systemPrompt: string;
127 | temperature: number;
128 | maxTokens: number;
129 | status: "idle" | "loading" | "success" | "error";
130 | outputContent?: string;
131 | error?: string;
132 | }
133 |
134 | // 文件上传节点数据
135 | export interface FileUploadNodeData {
136 | [key: string]: unknown;
137 | label: string;
138 | fileData?: string; // base64 编码的文件内容
139 | fileName?: string; // 文件名
140 | mimeType?: string; // MIME 类型
141 | fileSize?: number; // 文件大小(字节)
142 | }
143 |
144 | // PPT 内容节点相关类型(从 PPTContentNode/types.ts 重新导出)
145 | export type { PPTOutline, PPTPageStatus, PPTPageItem, PPTContentNodeData } from "@/components/nodes/PPTContentNode/types";
146 |
147 | // PPT 组装节点相关类型(从 PPTAssemblerNode/types.ts 重新导出)
148 | export type { PPTPageData, PPTAssemblerNodeData } from "@/components/nodes/PPTAssemblerNode/types";
149 |
150 | // 节点类型联合
151 | export type CustomNodeData =
152 | | PromptNodeData
153 | | ImageGeneratorNodeData
154 | | ImageInputNodeData
155 | | TextOutputNodeData
156 | | VideoGeneratorNodeData
157 | | LLMContentNodeData
158 | | FileUploadNodeData;
159 |
160 | // 自定义节点类型
161 | export type CustomNode = Node;
162 | export type CustomEdge = Edge;
163 |
164 | // 节点分类定义
165 | export interface NodeCategory {
166 | id: string;
167 | name: string;
168 | icon: string;
169 | nodes: NodeDefinition[];
170 | }
171 |
172 | export interface NodeDefinition {
173 | type: string;
174 | label: string;
175 | description: string;
176 | icon: string;
177 | defaultData: Record;
178 | inputs?: string[];
179 | outputs?: string[];
180 | }
181 |
182 | // 供应商配置
183 | export interface Provider {
184 | id: string; // 唯一标识 (uuid)
185 | name: string; // 供应商名称
186 | apiKey: string; // API Key
187 | baseUrl: string; // Base URL
188 | }
189 |
190 | // 节点类型到供应商的映射
191 | export interface NodeProviderMapping {
192 | imageGeneratorPro?: string; // Pro 图片节点使用的供应商 ID
193 | imageGeneratorFast?: string; // Fast 图片节点使用的供应商 ID
194 | videoGenerator?: string; // 视频节点使用的供应商 ID
195 | llm?: string; // PPT 内容生成节点使用的 LLM 供应商 ID
196 | llmContent?: string; // LLM 内容生成节点使用的供应商 ID
197 | }
198 |
199 | // 应用设置
200 | export interface AppSettings {
201 | providers: Provider[]; // 供应商列表
202 | nodeProviders: NodeProviderMapping; // 节点类型 -> 供应商映射
203 | theme: "light" | "dark" | "system";
204 | }
205 |
206 | // Store 状态
207 | export interface FlowState {
208 | nodes: CustomNode[];
209 | edges: CustomEdge[];
210 | selectedNodeId: string | null;
211 | }
212 |
213 | export interface SettingsState {
214 | settings: AppSettings;
215 | isSettingsOpen: boolean;
216 | }
217 |
--------------------------------------------------------------------------------
/src/components/nodes/ImageInputNode.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useCallback, useRef, useState } from "react";
2 | import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
3 | import { ImagePlus, Upload, X, Maximize2 } from "lucide-react";
4 | import { useFlowStore } from "@/stores/flowStore";
5 | import { useCanvasStore } from "@/stores/canvasStore";
6 | import { ImagePreviewModal } from "@/components/ui/ImagePreviewModal";
7 | import { saveImage, getImageUrl, isTauriEnvironment } from "@/services/fileStorageService";
8 | import type { ImageInputNodeData } from "@/types";
9 |
10 | // 定义节点类型
11 | type ImageInputNode = Node;
12 |
13 | // 图片输入节点
14 | export const ImageInputNode = memo(({ id, data, selected }: NodeProps) => {
15 | const updateNodeData = useFlowStore((state) => state.updateNodeData);
16 | const fileInputRef = useRef(null);
17 | const [showPreview, setShowPreview] = useState(false);
18 |
19 | const handleFileSelect = useCallback(
20 | async (e: React.ChangeEvent) => {
21 | const file = e.target.files?.[0];
22 | if (!file) return;
23 |
24 | const reader = new FileReader();
25 | reader.onload = async () => {
26 | const base64 = (reader.result as string).split(",")[1];
27 | // 在 Tauri 環境下,同步保存到文件系統,減少後續持久化時的數據體積
28 | if (isTauriEnvironment()) {
29 | try {
30 | const { activeCanvasId } = useCanvasStore.getState();
31 | const imageInfo = await saveImage(base64, activeCanvasId ?? undefined, id);
32 | updateNodeData(id, {
33 | imageData: base64,
34 | fileName: file.name,
35 | imagePath: imageInfo.path,
36 | });
37 | } catch {
38 | // 文件保存失敗時退回到僅 base64 存儲,保證功能可用
39 | updateNodeData(id, {
40 | imageData: base64,
41 | fileName: file.name,
42 | imagePath: undefined,
43 | });
44 | }
45 | } else {
46 | updateNodeData(id, {
47 | imageData: base64,
48 | fileName: file.name,
49 | });
50 | }
51 | };
52 | reader.readAsDataURL(file);
53 | },
54 | [id, updateNodeData]
55 | );
56 |
57 | const handleClearImage = useCallback(() => {
58 | updateNodeData(id, {
59 | imageData: undefined,
60 | fileName: undefined,
61 | imagePath: undefined,
62 | });
63 | if (fileInputRef.current) {
64 | fileInputRef.current.value = "";
65 | }
66 | }, [id, updateNodeData]);
67 |
68 | return (
69 | <>
70 |
76 | {/* 节点头部 */}
77 |
78 |
79 | {data.label}
80 |
81 |
82 | {/* 节点内容 */}
83 |
84 |
91 |
92 | {data.imageData || data.imagePath ? (
93 |
94 |
setShowPreview(true)}
97 | >
98 |

109 |
110 |
111 |
112 |
113 |
123 | {data.fileName && (
124 |
125 | {data.fileName}
126 |
127 | )}
128 |
129 | ) : (
130 |
138 | )}
139 |
140 |
141 | {/* 输出端口 - image 类型 */}
142 |
148 |
149 |
150 | {/* 预览弹窗 */}
151 | {showPreview && (data.imageData || data.imagePath) && (
152 | setShowPreview(false)}
156 | fileName={data.fileName}
157 | />
158 | )}
159 | >
160 | );
161 | });
162 |
163 | ImageInputNode.displayName = "ImageInputNode";
164 |
--------------------------------------------------------------------------------
/src/services/ocrInpaintService.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * OCR + Inpaint 服务调用
3 | * 用于将 PPT 图片转换为可编辑文字形式
4 | */
5 |
6 | import { invoke } from "@tauri-apps/api/core";
7 | import type { PPTPageData } from "@/components/nodes/PPTAssemblerNode/types";
8 |
9 | // ==================== 类型定义 ====================
10 |
11 | /** 文本框数据 */
12 | export interface TextBox {
13 | x: number;
14 | y: number;
15 | width: number;
16 | height: number;
17 | text: string;
18 | fontSize: number;
19 | }
20 |
21 | /** 处理后的页面数据 */
22 | export interface ProcessedPage {
23 | /** 去除文字后的背景图 (base64) */
24 | backgroundImage: string;
25 | /** 检测到的文本框列表 */
26 | textBoxes: TextBox[];
27 | /** 原始页面数据 */
28 | originalPage: PPTPageData;
29 | }
30 |
31 | /** 服务配置 */
32 | export interface OcrInpaintConfig {
33 | /** PaddleOCR 服务地址 */
34 | ocrApiUrl: string;
35 | /** IOPaint 服务地址 */
36 | inpaintApiUrl: string;
37 | /** 蒙版扩展边距(像素) */
38 | maskPadding?: number;
39 | }
40 |
41 | /** 处理进度回调 */
42 | export type ProgressCallback = (current: number, total: number) => void;
43 |
44 | /** 单页处理结果(来自 Rust) */
45 | interface ProcessPageResult {
46 | success: boolean;
47 | backgroundImage: string | null;
48 | textBoxes: TextBox[];
49 | error: string | null;
50 | }
51 |
52 | /** 连接测试结果 */
53 | interface TestConnectionResult {
54 | success: boolean;
55 | message: string;
56 | }
57 |
58 | // ==================== 服务函数 ====================
59 |
60 | /**
61 | * 检测 Tauri 环境
62 | */
63 | function isTauriEnvironment(): boolean {
64 | return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
65 | }
66 |
67 | /**
68 | * 处理单个 PPT 页面
69 | * @param imageData base64 编码的图片
70 | * @param config 服务配置
71 | * @returns 处理结果
72 | */
73 | export async function processPageForEditable(
74 | imageData: string,
75 | config: OcrInpaintConfig
76 | ): Promise<{ backgroundImage: string; textBoxes: TextBox[] }> {
77 | if (!isTauriEnvironment()) {
78 | throw new Error("此功能仅在 Tauri 环境中可用");
79 | }
80 |
81 | const result = await invoke("process_ppt_page", {
82 | params: {
83 | imageData,
84 | ocrApiUrl: config.ocrApiUrl,
85 | inpaintApiUrl: config.inpaintApiUrl,
86 | maskPadding: config.maskPadding ?? 5,
87 | },
88 | });
89 |
90 | if (!result.success || !result.backgroundImage) {
91 | throw new Error(result.error || "处理失败");
92 | }
93 |
94 | return {
95 | backgroundImage: result.backgroundImage,
96 | textBoxes: result.textBoxes,
97 | };
98 | }
99 |
100 | /** 批量处理结果 */
101 | export interface ProcessAllPagesResult {
102 | /** 是否全部成功 */
103 | success: boolean;
104 | /** 处理后的页面(仅成功时有值) */
105 | pages: ProcessedPage[];
106 | /** 错误信息(失败时) */
107 | error?: string;
108 | /** 失败的页面编号 */
109 | failedPageNumber?: number;
110 | }
111 |
112 | /**
113 | * 批量处理所有 PPT 页面
114 | * @param pages PPT 页面数据数组
115 | * @param config 服务配置
116 | * @param onProgress 进度回调
117 | * @returns 处理结果,包含成功/失败状态
118 | */
119 | export async function processAllPages(
120 | pages: PPTPageData[],
121 | config: OcrInpaintConfig,
122 | onProgress?: ProgressCallback
123 | ): Promise {
124 | if (!isTauriEnvironment()) {
125 | return {
126 | success: false,
127 | pages: [],
128 | error: "此功能仅在 Tauri 环境中可用",
129 | };
130 | }
131 |
132 | // 先检查服务是否可用
133 | const servicesCheck = await checkServicesAvailable(config);
134 |
135 | if (!servicesCheck.ocrAvailable) {
136 | return {
137 | success: false,
138 | pages: [],
139 | error: `OCR 服务连接失败: ${servicesCheck.ocrMessage}`,
140 | };
141 | }
142 |
143 | if (!servicesCheck.inpaintAvailable) {
144 | return {
145 | success: false,
146 | pages: [],
147 | error: `IOPaint 服务连接失败: ${servicesCheck.inpaintMessage}`,
148 | };
149 | }
150 |
151 | const results: ProcessedPage[] = [];
152 | const total = pages.length;
153 |
154 | for (let i = 0; i < pages.length; i++) {
155 | const page = pages[i];
156 |
157 | // 报告进度
158 | onProgress?.(i + 1, total);
159 |
160 | try {
161 | const processed = await processPageForEditable(page.image, config);
162 |
163 | results.push({
164 | backgroundImage: processed.backgroundImage,
165 | textBoxes: processed.textBoxes,
166 | originalPage: page,
167 | });
168 | } catch (error) {
169 | // 处理失败,立即停止并返回错误
170 | const errorMessage = error instanceof Error ? error.message : String(error);
171 | console.error(`第 ${page.pageNumber} 页处理失败:`, error);
172 |
173 | return {
174 | success: false,
175 | pages: results, // 返回已处理的页面
176 | error: `第 ${page.pageNumber} 页处理失败: ${errorMessage}`,
177 | failedPageNumber: page.pageNumber,
178 | };
179 | }
180 | }
181 |
182 | return {
183 | success: true,
184 | pages: results,
185 | };
186 | }
187 |
188 | /**
189 | * 测试 OCR 服务连接
190 | * @param url OCR 服务地址
191 | */
192 | export async function testOcrConnection(url: string): Promise {
193 | if (!isTauriEnvironment()) {
194 | return { success: false, message: "此功能仅在 Tauri 环境中可用" };
195 | }
196 |
197 | return await invoke("test_ocr_connection", {
198 | params: { url },
199 | });
200 | }
201 |
202 | /**
203 | * 测试 IOPaint 服务连接
204 | * @param url IOPaint 服务地址
205 | */
206 | export async function testInpaintConnection(url: string): Promise {
207 | if (!isTauriEnvironment()) {
208 | return { success: false, message: "此功能仅在 Tauri 环境中可用" };
209 | }
210 |
211 | return await invoke("test_inpaint_connection", {
212 | params: { url },
213 | });
214 | }
215 |
216 | /**
217 | * 检查服务是否可用(同时测试 OCR 和 IOPaint)
218 | */
219 | export async function checkServicesAvailable(config: OcrInpaintConfig): Promise<{
220 | ocrAvailable: boolean;
221 | inpaintAvailable: boolean;
222 | ocrMessage: string;
223 | inpaintMessage: string;
224 | }> {
225 | const [ocrResult, inpaintResult] = await Promise.all([
226 | testOcrConnection(config.ocrApiUrl),
227 | testInpaintConnection(config.inpaintApiUrl),
228 | ]);
229 |
230 | return {
231 | ocrAvailable: ocrResult.success,
232 | inpaintAvailable: inpaintResult.success,
233 | ocrMessage: ocrResult.message,
234 | inpaintMessage: inpaintResult.message,
235 | };
236 | }
237 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTContentNode/PageList.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import { Play, Pause, RotateCcw, CheckCircle, AlertCircle } from "lucide-react";
3 | import type { PPTPageItem } from "./types";
4 | import { PageItemRow } from "./PageItemRow";
5 |
6 | interface PageListProps {
7 | pages: PPTPageItem[];
8 | generationStatus: "idle" | "running" | "paused" | "completed" | "error";
9 | progress: { completed: number; total: number };
10 | onStartAll: () => void;
11 | onPauseAll: () => void;
12 | onResumeAll: () => void;
13 | onRetryFailed: () => void;
14 | onRetryPage: (id: string) => void;
15 | onSkipPage: (id: string) => void;
16 | onRunPage: (id: string) => void;
17 | onStopPage: (id: string) => void;
18 | onUploadImage: (id: string, imageData: string) => void;
19 | onShowScript?: (item: PPTPageItem) => void;
20 | }
21 |
22 | export function PageList({
23 | pages,
24 | generationStatus,
25 | progress,
26 | onStartAll,
27 | onPauseAll,
28 | onResumeAll,
29 | onRetryFailed,
30 | onRetryPage,
31 | onSkipPage,
32 | onRunPage,
33 | onStopPage,
34 | onUploadImage,
35 | onShowScript,
36 | }: PageListProps) {
37 | // 计算统计信息
38 | const stats = useMemo(() => {
39 | const pending = pages.filter(p => p.status === "pending").length;
40 | const running = pages.filter(p => p.status === "running").length;
41 | const completed = pages.filter(p => p.status === "completed").length;
42 | const failed = pages.filter(p => p.status === "failed").length;
43 | const skipped = pages.filter(p => p.status === "skipped").length;
44 | return { pending, running, completed, failed, skipped };
45 | }, [pages]);
46 |
47 | // 进度百分比
48 | const progressPercent = progress.total > 0
49 | ? Math.round((progress.completed / progress.total) * 100)
50 | : 0;
51 |
52 | // 是否有失败的页面
53 | const hasFailed = stats.failed > 0;
54 |
55 | // 是否正在运行
56 | const isRunning = generationStatus === "running";
57 | const isPaused = generationStatus === "paused";
58 | const isCompleted = generationStatus === "completed";
59 | const isIdle = generationStatus === "idle";
60 |
61 | return (
62 |
63 | {/* 顶部控制栏 */}
64 |
65 |
66 | {/* 状态指示 */}
67 | {isRunning && (
68 |
69 |
70 | 生成中
71 |
72 | )}
73 | {isPaused && (
74 |
已暂停
75 | )}
76 | {isCompleted && (
77 |
78 |
79 | 已完成
80 |
81 | )}
82 | {hasFailed && !isRunning && (
83 |
84 |
85 | {stats.failed} 失败
86 |
87 | )}
88 |
89 |
90 | {/* 控制按钮 */}
91 |
92 | {isIdle && (
93 |
100 | )}
101 | {isRunning && (
102 |
109 | )}
110 | {isPaused && (
111 |
118 | )}
119 | {hasFailed && !isRunning && (
120 |
127 | )}
128 |
129 |
130 |
131 | {/* 进度条 */}
132 |
133 |
134 | 进度:{progress.completed}/{progress.total}
135 | {progressPercent}%
136 |
137 |
142 |
143 |
144 | {/* 统计摘要 */}
145 |
146 |
147 |
148 | 待生成 {stats.pending}
149 |
150 |
151 |
152 | 生成中 {stats.running}
153 |
154 |
155 |
156 | 完成 {stats.completed}
157 |
158 | {stats.failed > 0 && (
159 |
160 |
161 | 失败 {stats.failed}
162 |
163 | )}
164 | {stats.skipped > 0 && (
165 |
166 |
167 | 跳过 {stats.skipped}
168 |
169 | )}
170 |
171 |
172 | {/* 页面列表 */}
173 |
174 | {pages.map((page) => (
175 |
186 | ))}
187 |
188 |
189 | );
190 | }
191 |
--------------------------------------------------------------------------------
/src/components/nodes/FileUploadNode.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useCallback, useRef } from "react";
2 | import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
3 | import { FileUp, Upload, X, FileText, FileImage, FileAudio, FileVideo, File } from "lucide-react";
4 | import { useFlowStore } from "@/stores/flowStore";
5 | import type { FileUploadNodeData } from "@/types";
6 |
7 | // 定义节点类型
8 | type FileUploadNode = Node;
9 |
10 | // 支持的文件类型
11 | const SUPPORTED_MIME_TYPES = [
12 | // 图片
13 | "image/jpeg",
14 | "image/png",
15 | "image/gif",
16 | "image/webp",
17 | "image/heic",
18 | "image/heif",
19 | // PDF
20 | "application/pdf",
21 | // 音频
22 | "audio/mpeg",
23 | "audio/mp3",
24 | "audio/wav",
25 | "audio/webm",
26 | "audio/aac",
27 | "audio/ogg",
28 | "audio/flac",
29 | // 视频
30 | "video/mp4",
31 | "video/mpeg",
32 | "video/mov",
33 | "video/avi",
34 | "video/x-flv",
35 | "video/mpg",
36 | "video/webm",
37 | "video/wmv",
38 | "video/3gpp",
39 | ];
40 |
41 | // 获取文件类型图标
42 | const getFileIcon = (mimeType?: string) => {
43 | if (!mimeType) return File;
44 | if (mimeType.startsWith("image/")) return FileImage;
45 | if (mimeType.startsWith("audio/")) return FileAudio;
46 | if (mimeType.startsWith("video/")) return FileVideo;
47 | if (mimeType === "application/pdf") return FileText;
48 | return File;
49 | };
50 |
51 | // 格式化文件大小
52 | const formatFileSize = (bytes?: number) => {
53 | if (!bytes) return "0 B";
54 | const units = ["B", "KB", "MB", "GB"];
55 | let size = bytes;
56 | let unitIndex = 0;
57 | while (size >= 1024 && unitIndex < units.length - 1) {
58 | size /= 1024;
59 | unitIndex++;
60 | }
61 | return `${size.toFixed(1)} ${units[unitIndex]}`;
62 | };
63 |
64 | // 获取文件类型显示名称
65 | const getFileTypeName = (mimeType?: string) => {
66 | if (!mimeType) return "未知";
67 | if (mimeType.startsWith("image/")) return "图片";
68 | if (mimeType.startsWith("audio/")) return "音频";
69 | if (mimeType.startsWith("video/")) return "视频";
70 | if (mimeType === "application/pdf") return "PDF";
71 | return mimeType.split("/")[1]?.toUpperCase() || "文件";
72 | };
73 |
74 | // 文件上传节点
75 | export const FileUploadNode = memo(({ id, data, selected }: NodeProps) => {
76 | const updateNodeData = useFlowStore((state) => state.updateNodeData);
77 | const fileInputRef = useRef(null);
78 |
79 | const handleFileSelect = useCallback(
80 | async (e: React.ChangeEvent) => {
81 | const file = e.target.files?.[0];
82 | if (!file) return;
83 |
84 | // 检查文件类型
85 | if (!SUPPORTED_MIME_TYPES.includes(file.type)) {
86 | alert(`不支持的文件类型: ${file.type}\n支持的类型: 图片、PDF、音频、视频`);
87 | return;
88 | }
89 |
90 | const reader = new FileReader();
91 | reader.onload = () => {
92 | const base64 = (reader.result as string).split(",")[1];
93 | updateNodeData(id, {
94 | fileData: base64,
95 | fileName: file.name,
96 | mimeType: file.type,
97 | fileSize: file.size,
98 | });
99 | };
100 | reader.readAsDataURL(file);
101 | },
102 | [id, updateNodeData]
103 | );
104 |
105 | const handleClearFile = useCallback(() => {
106 | updateNodeData(id, {
107 | fileData: undefined,
108 | fileName: undefined,
109 | mimeType: undefined,
110 | fileSize: undefined,
111 | });
112 | if (fileInputRef.current) {
113 | fileInputRef.current.value = "";
114 | }
115 | }, [id, updateNodeData]);
116 |
117 | const FileIcon = getFileIcon(data.mimeType);
118 |
119 | return (
120 |
126 | {/* 节点头部 */}
127 |
128 |
129 | {data.label}
130 |
131 |
132 | {/* 节点内容 */}
133 |
134 |
141 |
142 | {data.fileData ? (
143 |
144 | {/* 文件信息展示 */}
145 |
146 |
147 |
148 |
149 | {data.fileName}
150 |
151 |
152 |
153 | {getFileTypeName(data.mimeType)}
154 |
155 |
156 | {formatFileSize(data.fileSize)}
157 |
158 |
159 |
160 |
161 | {/* 清除按钮 */}
162 |
172 |
173 | ) : (
174 |
183 | )}
184 |
185 |
186 | {/* 输出端口 - file 类型 */}
187 |
193 |
194 | );
195 | });
196 |
197 | FileUploadNode.displayName = "FileUploadNode";
198 |
--------------------------------------------------------------------------------
/src/stores/storageManagementStore.ts:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 | import {
3 | getStorageStats,
4 | getStoragePath,
5 | clearCache,
6 | clearAllImages,
7 | deleteCanvasImages,
8 | listCanvasImages,
9 | deleteImage,
10 | isTauriEnvironment,
11 | type StorageStats,
12 | type ImageInfo,
13 | } from "@/services/fileStorageService";
14 |
15 | // 展开的画布 ID 集合
16 | export type ExpandedCanvases = Set;
17 |
18 | interface StorageManagementState {
19 | // UI 状态
20 | isOpen: boolean;
21 | isLoading: boolean;
22 | isTauri: boolean;
23 |
24 | // 文件存储数据(桌面端)
25 | fileStats: StorageStats | null;
26 | storagePath: string | null;
27 | expandedFileCanvases: string[]; // 展开的画布(文件存储)
28 | canvasImages: Map; // 画布图片详情
29 |
30 | // 错误信息
31 | error: string | null;
32 |
33 | // 操作
34 | openModal: () => void;
35 | closeModal: () => void;
36 | refreshStats: () => Promise;
37 |
38 | // 文件存储操作
39 | handleClearCache: () => Promise;
40 | handleClearAllImages: () => Promise;
41 | handleClearCanvasImages: (canvasId: string) => Promise;
42 | handleDeleteImage: (path: string) => Promise;
43 | toggleFileCanvasExpanded: (canvasId: string) => Promise;
44 | loadCanvasImages: (canvasId: string) => Promise;
45 | }
46 |
47 | export const useStorageManagementStore = create(
48 | (set, get) => ({
49 | isOpen: false,
50 | isLoading: false,
51 | isTauri: isTauriEnvironment(),
52 |
53 | fileStats: null,
54 | storagePath: null,
55 | expandedFileCanvases: [],
56 | canvasImages: new Map(),
57 |
58 | error: null,
59 |
60 | openModal: async () => {
61 | const isTauri = isTauriEnvironment();
62 | set({
63 | isOpen: true,
64 | isLoading: true,
65 | error: null,
66 | isTauri,
67 | });
68 |
69 | try {
70 | if (isTauri) {
71 | const [fileStats, storagePath] = await Promise.all([
72 | getStorageStats(),
73 | getStoragePath(),
74 | ]);
75 | set({
76 | fileStats,
77 | storagePath,
78 | isLoading: false,
79 | });
80 | } else {
81 | // 浏览器环境:显示基本信息
82 | set({
83 | storagePath: "浏览器 localStorage",
84 | isLoading: false,
85 | });
86 | }
87 | } catch (err) {
88 | set({
89 | error: err instanceof Error ? err.message : "获取存储信息失败",
90 | isLoading: false,
91 | });
92 | }
93 | },
94 |
95 | closeModal: () => {
96 | set({
97 | isOpen: false,
98 | expandedFileCanvases: [],
99 | canvasImages: new Map(),
100 | });
101 | },
102 |
103 | refreshStats: async () => {
104 | const { isTauri } = get();
105 | set({ isLoading: true, error: null });
106 |
107 | try {
108 | if (isTauri) {
109 | const fileStats = await getStorageStats();
110 | set({ fileStats, isLoading: false });
111 | } else {
112 | set({ isLoading: false });
113 | }
114 | } catch (err) {
115 | set({
116 | error: err instanceof Error ? err.message : "刷新失败",
117 | isLoading: false,
118 | });
119 | }
120 | },
121 |
122 | // === 文件存储操作 ===
123 |
124 | handleClearCache: async () => {
125 | if (!isTauriEnvironment()) return;
126 |
127 | set({ isLoading: true, error: null });
128 | try {
129 | await clearCache();
130 | await get().refreshStats();
131 | } catch (err) {
132 | set({
133 | error: err instanceof Error ? err.message : "清理缓存失败",
134 | isLoading: false,
135 | });
136 | }
137 | },
138 |
139 | handleClearAllImages: async () => {
140 | if (!isTauriEnvironment()) return;
141 |
142 | set({ isLoading: true, error: null });
143 | try {
144 | await clearAllImages();
145 | set({ canvasImages: new Map(), expandedFileCanvases: [] });
146 | await get().refreshStats();
147 | } catch (err) {
148 | set({
149 | error: err instanceof Error ? err.message : "清理图片失败",
150 | isLoading: false,
151 | });
152 | }
153 | },
154 |
155 | handleClearCanvasImages: async (canvasId: string) => {
156 | if (!isTauriEnvironment()) return;
157 |
158 | set({ isLoading: true, error: null });
159 | try {
160 | await deleteCanvasImages(canvasId);
161 | // 从 canvasImages 中移除
162 | const newCanvasImages = new Map(get().canvasImages);
163 | newCanvasImages.delete(canvasId);
164 | set({ canvasImages: newCanvasImages });
165 | await get().refreshStats();
166 | } catch (err) {
167 | set({
168 | error: err instanceof Error ? err.message : "清理画布图片失败",
169 | isLoading: false,
170 | });
171 | }
172 | },
173 |
174 | handleDeleteImage: async (path: string) => {
175 | if (!isTauriEnvironment()) return;
176 |
177 | set({ isLoading: true, error: null });
178 | try {
179 | await deleteImage(path);
180 | // 更新 canvasImages 中的数据
181 | const newCanvasImages = new Map(get().canvasImages);
182 | for (const [canvasId, images] of newCanvasImages) {
183 | const filtered = images.filter((img) => img.path !== path);
184 | if (filtered.length !== images.length) {
185 | newCanvasImages.set(canvasId, filtered);
186 | }
187 | }
188 | set({ canvasImages: newCanvasImages });
189 | await get().refreshStats();
190 | } catch (err) {
191 | set({
192 | error: err instanceof Error ? err.message : "删除图片失败",
193 | isLoading: false,
194 | });
195 | }
196 | },
197 |
198 | toggleFileCanvasExpanded: async (canvasId: string) => {
199 | const { expandedFileCanvases, canvasImages } = get();
200 | const isExpanded = expandedFileCanvases.includes(canvasId);
201 |
202 | if (isExpanded) {
203 | // 收起
204 | set({
205 | expandedFileCanvases: expandedFileCanvases.filter((id) => id !== canvasId),
206 | });
207 | } else {
208 | // 展开并加载图片列表
209 | set({
210 | expandedFileCanvases: [...expandedFileCanvases, canvasId],
211 | });
212 |
213 | // 如果还没加载过,则加载
214 | if (!canvasImages.has(canvasId)) {
215 | await get().loadCanvasImages(canvasId);
216 | }
217 | }
218 | },
219 |
220 | loadCanvasImages: async (canvasId: string) => {
221 | if (!isTauriEnvironment()) return;
222 |
223 | try {
224 | const images = await listCanvasImages(canvasId);
225 | const newCanvasImages = new Map(get().canvasImages);
226 | newCanvasImages.set(canvasId, images);
227 | set({ canvasImages: newCanvasImages });
228 | } catch (err) {
229 | console.error("加载画布图片列表失败:", err);
230 | }
231 | },
232 | })
233 | );
234 |
--------------------------------------------------------------------------------
/src/components/ui/ImagePreviewModal.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, useEffect } from "react";
2 | import { createPortal } from "react-dom";
3 | import { X, Download, ZoomIn, ZoomOut, Loader2 } from "lucide-react";
4 | import { getImageUrl, isTauriEnvironment, readImage } from "@/services/fileStorageService";
5 | import { toast } from "@/stores/toastStore";
6 |
7 | interface ImagePreviewModalProps {
8 | imageData?: string; // base64 数据(可选)
9 | imagePath?: string; // 文件路径(可选)
10 | onClose: () => void;
11 | fileName?: string;
12 | }
13 |
14 | export function ImagePreviewModal({ imageData, imagePath, onClose, fileName }: ImagePreviewModalProps) {
15 | const [scale, setScale] = useState(1);
16 | const [isVisible, setIsVisible] = useState(false);
17 | const [isClosing, setIsClosing] = useState(false);
18 | const [isDownloading, setIsDownloading] = useState(false);
19 |
20 | // 进入动画
21 | useEffect(() => {
22 | requestAnimationFrame(() => setIsVisible(true));
23 | }, []);
24 |
25 | // 获取图片 URL
26 | const imageUrl = imagePath
27 | ? getImageUrl(imagePath)
28 | : imageData
29 | ? `data:image/png;base64,${imageData}`
30 | : "";
31 |
32 | // 关闭时先播放退出动画
33 | const handleClose = useCallback(() => {
34 | setIsClosing(true);
35 | setIsVisible(false);
36 | setTimeout(onClose, 200);
37 | }, [onClose]);
38 |
39 | const handleDownload = useCallback(async () => {
40 | if (isDownloading) return;
41 |
42 | setIsDownloading(true);
43 |
44 | try {
45 | // 获取 base64 数据
46 | let base64Data: string;
47 |
48 | if (imageData) {
49 | base64Data = imageData;
50 | } else if (imagePath) {
51 | // 从文件路径读取 base64 数据
52 | base64Data = await readImage(imagePath);
53 | } else {
54 | toast.error("没有可下载的图片数据");
55 | return;
56 | }
57 |
58 | const defaultFileName = fileName || `next-creator-${Date.now()}.png`;
59 |
60 | if (isTauriEnvironment()) {
61 | // Tauri 环境:使用保存对话框
62 | const { save } = await import("@tauri-apps/plugin-dialog");
63 | const { writeFile } = await import("@tauri-apps/plugin-fs");
64 |
65 | const filePath = await save({
66 | defaultPath: defaultFileName,
67 | filters: [{ name: "图片", extensions: ["png", "jpg", "jpeg", "webp"] }],
68 | });
69 |
70 | if (filePath) {
71 | // 将 base64 转换为 Uint8Array
72 | const binaryString = atob(base64Data);
73 | const bytes = new Uint8Array(binaryString.length);
74 | for (let i = 0; i < binaryString.length; i++) {
75 | bytes[i] = binaryString.charCodeAt(i);
76 | }
77 |
78 | await writeFile(filePath, bytes);
79 | toast.success(`图片已保存到: ${filePath.split("/").pop()}`);
80 | }
81 | } else {
82 | // 浏览器环境:使用传统下载
83 | const link = document.createElement("a");
84 | link.href = `data:image/png;base64,${base64Data}`;
85 | link.download = defaultFileName;
86 | document.body.appendChild(link);
87 | link.click();
88 | document.body.removeChild(link);
89 | toast.success("图片下载已开始");
90 | }
91 | } catch (error) {
92 | console.error("下载失败:", error);
93 | toast.error(`下载失败: ${error instanceof Error ? error.message : "未知错误"}`);
94 | } finally {
95 | setIsDownloading(false);
96 | }
97 | }, [imageData, imagePath, fileName, isDownloading]);
98 |
99 | const handleZoomIn = () => setScale((s) => Math.min(s + 0.25, 3));
100 | const handleZoomOut = () => setScale((s) => Math.max(s - 0.25, 0.5));
101 |
102 | // ESC 键关闭
103 | useEffect(() => {
104 | const handleKeyDown = (e: KeyboardEvent) => {
105 | if (e.key === "Escape") {
106 | handleClose();
107 | }
108 | };
109 | document.addEventListener("keydown", handleKeyDown);
110 | return () => document.removeEventListener("keydown", handleKeyDown);
111 | }, [handleClose]);
112 |
113 | // 使用 Portal 渲染到 body,避免被节点的 transform 影响
114 | return createPortal(
115 |
123 | {/* 工具栏 */}
124 |
e.stopPropagation()}
131 | >
132 |
138 |
139 | {Math.round(scale * 100)}%
140 |
141 |
147 |
148 |
160 |
166 |
167 |
168 | {/* 图片 */}
169 |
e.stopPropagation()}
179 | >
180 |

186 |
187 |
188 | {/* 提示 */}
189 |
196 | 点击背景或按 ESC 关闭
197 |
198 |
,
199 | document.body
200 | );
201 | }
202 |
--------------------------------------------------------------------------------
/src/components/nodes/PPTContentNode/BuiltinTemplateModal.tsx:
--------------------------------------------------------------------------------
1 | import { memo, useState, useEffect, useCallback } from "react";
2 | import { createPortal } from "react-dom";
3 | import { X, Check, Loader2, ImageIcon } from "lucide-react";
4 |
5 | // 内置模板定义
6 | export interface BuiltinTemplate {
7 | id: string;
8 | name: string;
9 | description: string;
10 | thumbnailUrl: string; // 缩略图 URL(用于展示)
11 | fullUrl: string; // 完整图片 URL(用于加载)
12 | }
13 |
14 | // 内置模板列表
15 | export const BUILTIN_TEMPLATES: BuiltinTemplate[] = [
16 | {
17 | id: "template_1",
18 | name: "简约蓝",
19 | description: "简洁的蓝色调商务模板,适合学术和技术演示",
20 | thumbnailUrl: "/templates/template_1.png",
21 | fullUrl: "/templates/template_1.png",
22 | },
23 | // 后续可以添加更多模板
24 | ];
25 |
26 | interface BuiltinTemplateModalProps {
27 | isOpen: boolean;
28 | onClose: () => void;
29 | onSelect: (template: BuiltinTemplate, imageData: string) => void;
30 | }
31 |
32 | // 将图片 URL 转换为 base64
33 | async function imageUrlToBase64(url: string): Promise {
34 | const response = await fetch(url);
35 | const blob = await response.blob();
36 | return new Promise((resolve, reject) => {
37 | const reader = new FileReader();
38 | reader.onload = () => {
39 | const result = reader.result as string;
40 | // 提取 base64 部分(去掉 data:image/xxx;base64, 前缀)
41 | const base64 = result.split(",")[1];
42 | resolve(base64);
43 | };
44 | reader.onerror = reject;
45 | reader.readAsDataURL(blob);
46 | });
47 | }
48 |
49 | export const BuiltinTemplateModal = memo(({ isOpen, onClose, onSelect }: BuiltinTemplateModalProps) => {
50 | const [isVisible, setIsVisible] = useState(false);
51 | const [selectedId, setSelectedId] = useState(null);
52 | const [isLoading, setIsLoading] = useState(false);
53 |
54 | // 处理弹窗动画
55 | useEffect(() => {
56 | if (isOpen) {
57 | requestAnimationFrame(() => setIsVisible(true));
58 | } else {
59 | setIsVisible(false);
60 | }
61 | }, [isOpen]);
62 |
63 | // 处理关闭
64 | const handleClose = useCallback(() => {
65 | setIsVisible(false);
66 | setTimeout(() => {
67 | onClose();
68 | setSelectedId(null);
69 | }, 200);
70 | }, [onClose]);
71 |
72 | // 处理选择确认
73 | const handleConfirm = useCallback(async () => {
74 | if (!selectedId) return;
75 |
76 | const template = BUILTIN_TEMPLATES.find(t => t.id === selectedId);
77 | if (!template) return;
78 |
79 | setIsLoading(true);
80 | try {
81 | // 加载图片并转换为 base64
82 | const imageData = await imageUrlToBase64(template.fullUrl);
83 | onSelect(template, imageData);
84 | handleClose();
85 | } catch (error) {
86 | console.error("加载模板图片失败:", error);
87 | } finally {
88 | setIsLoading(false);
89 | }
90 | }, [selectedId, onSelect, handleClose]);
91 |
92 | if (!isOpen) return null;
93 |
94 | return createPortal(
95 |
103 |
e.stopPropagation()}
113 | >
114 | {/* 头部 */}
115 |
116 |
117 |
118 | 选择内置模板
119 |
120 |
126 |
127 |
128 | {/* 模板列表 */}
129 |
130 |
131 | {BUILTIN_TEMPLATES.map((template) => {
132 | const isSelected = selectedId === template.id;
133 | return (
134 |
setSelectedId(template.id)}
145 | >
146 | {/* 缩略图 */}
147 |
148 |

153 | {/* 选中指示器 */}
154 | {isSelected && (
155 |
156 |
157 |
158 | )}
159 |
160 | {/* 信息 */}
161 |
162 |
{template.name}
163 |
164 | {template.description}
165 |
166 |
167 |
168 | );
169 | })}
170 |
171 |
172 | {/* 空状态提示 */}
173 | {BUILTIN_TEMPLATES.length === 0 && (
174 |
178 | )}
179 |
180 |
181 | {/* 底部操作 */}
182 |
183 |
189 |
203 |
204 |
205 |
,
206 | document.body
207 | );
208 | });
209 |
210 | BuiltinTemplateModal.displayName = "BuiltinTemplateModal";
211 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @plugin "daisyui";
3 |
4 | /* 自定义主题配置 */
5 | @theme {
6 | --color-primary: oklch(65% 0.19 255);
7 | --color-secondary: oklch(70% 0.15 280);
8 | --color-accent: oklch(75% 0.18 150);
9 | --color-neutral: oklch(30% 0.02 260);
10 | --color-base-100: oklch(98% 0.01 260);
11 | --color-base-200: oklch(95% 0.01 260);
12 | --color-base-300: oklch(90% 0.02 260);
13 | }
14 |
15 | /* 基础样式 */
16 | html, body, #root {
17 | @apply h-full w-full m-0 p-0 overflow-hidden;
18 | }
19 |
20 | body {
21 | @apply bg-base-200 text-base-content;
22 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
23 | /* 优化 WebView 字体渲染,防止模糊 */
24 | -webkit-font-smoothing: antialiased;
25 | -moz-osx-font-smoothing: grayscale;
26 | text-rendering: optimizeLegibility;
27 | }
28 |
29 | /* React Flow 自定义样式 */
30 | .react-flow__node {
31 | /* 防止节点内容在 WebView 中模糊 */
32 | -webkit-backface-visibility: hidden;
33 | backface-visibility: hidden;
34 | -webkit-font-smoothing: antialiased;
35 | -moz-osx-font-smoothing: grayscale;
36 | }
37 |
38 | /* 防止节点内滚动容器导致模糊 */
39 | .react-flow__node [class*="overflow-"] {
40 | -webkit-backface-visibility: hidden;
41 | backface-visibility: hidden;
42 | }
43 |
44 | .react-flow__edge-path {
45 | @apply stroke-2;
46 | }
47 |
48 | /* 邊選中時的高亮樣式,避免在渲染時逐條重建 style 對象 */
49 | .react-flow__edge.selected .react-flow__edge-path {
50 | stroke-width: 3;
51 | stroke: #3b82f6;
52 | }
53 |
54 | .react-flow__controls {
55 | @apply bg-base-100 border border-base-300 rounded-lg shadow-md;
56 | }
57 |
58 | .react-flow__controls-button {
59 | @apply bg-base-100 border-base-300 hover:bg-base-200;
60 | }
61 |
62 | .react-flow__minimap {
63 | @apply bg-base-100 border border-base-300 rounded-lg shadow-md;
64 | }
65 |
66 | .react-flow__background {
67 | @apply bg-base-200;
68 | }
69 |
70 | /* 滚动条样式 */
71 | ::-webkit-scrollbar {
72 | @apply w-2 h-2;
73 | }
74 |
75 | ::-webkit-scrollbar-track {
76 | @apply bg-base-200 rounded;
77 | }
78 |
79 | ::-webkit-scrollbar-thumb {
80 | @apply bg-base-300 rounded hover:bg-neutral/30;
81 | }
82 |
83 | /* 节点面板拖拽样式 */
84 | .draggable-node {
85 | @apply cursor-grab active:cursor-grabbing;
86 | }
87 |
88 | /* 动画:只做透明度渐显,避免额外 transform 影响 WebView 合成 */
89 | @keyframes fadeIn {
90 | from { opacity: 0; }
91 | to { opacity: 1; }
92 | }
93 |
94 | .animate-fade-in {
95 | animation: fadeIn 0.2s ease-out;
96 | }
97 |
98 | /* Modal 弹窗动画 - 避免 scale 导致模糊 */
99 | @keyframes modalBackdropIn {
100 | from { opacity: 0; }
101 | to { opacity: 1; }
102 | }
103 |
104 | @keyframes modalBackdropOut {
105 | from { opacity: 1; }
106 | to { opacity: 0; }
107 | }
108 |
109 | @keyframes modalContentIn {
110 | from {
111 | opacity: 0;
112 | }
113 | to {
114 | opacity: 1;
115 | }
116 | }
117 |
118 | @keyframes modalContentOut {
119 | from {
120 | opacity: 1;
121 | }
122 | to {
123 | opacity: 0;
124 | }
125 | }
126 |
127 | /* Modal 遮罩动画类 */
128 | .modal-backdrop-enter {
129 | animation: modalBackdropIn 0.2s ease-out forwards;
130 | }
131 |
132 | .modal-backdrop-exit {
133 | animation: modalBackdropOut 0.15s ease-in forwards;
134 | }
135 |
136 | /* Modal 内容动画类 */
137 | .modal-content-enter {
138 | animation: modalContentIn 0.2s ease-out forwards;
139 | }
140 |
141 | .modal-content-exit {
142 | animation: modalContentOut 0.15s ease-in forwards;
143 | }
144 |
145 | /* 弹出动画 - 避免 scale 导致模糊 */
146 | @keyframes scaleIn {
147 | from {
148 | opacity: 0;
149 | }
150 | to {
151 | opacity: 1;
152 | }
153 | }
154 |
155 | .animate-scale-in {
156 | animation: scaleIn 0.15s ease-out forwards;
157 | }
158 |
159 | /* 滑入动画 - 避免 transform 导致模糊 */
160 | @keyframes slideInUp {
161 | from {
162 | opacity: 0;
163 | }
164 | to {
165 | opacity: 1;
166 | }
167 | }
168 |
169 | .animate-slide-in-up {
170 | animation: slideInUp 0.25s ease-out forwards;
171 | }
172 |
173 | /* 按钮点击效果 - 避免 scale 导致模糊 */
174 | .btn {
175 | @apply relative overflow-hidden;
176 | transition: opacity 0.15s ease-out, box-shadow 0.15s ease-out;
177 | }
178 |
179 | .btn:active:not(:disabled) {
180 | opacity: 0.85;
181 | }
182 |
183 | /* 卡片悬停效果 - 避免 translate 导致模糊 */
184 | .card-hover {
185 | @apply transition-all duration-200;
186 | }
187 |
188 | .card-hover:hover {
189 | @apply shadow-lg;
190 | }
191 |
192 | /* 标签页内容切换动画 - 避免 transform 导致模糊 */
193 | @keyframes tabContentIn {
194 | from {
195 | opacity: 0;
196 | }
197 | to {
198 | opacity: 1;
199 | }
200 | }
201 |
202 | .animate-tab-content {
203 | animation: tabContentIn 0.2s ease-out forwards;
204 | }
205 |
206 | /* 图片预览样式 */
207 | .image-preview {
208 | @apply relative overflow-hidden rounded-lg bg-base-300;
209 | }
210 |
211 | .image-preview img {
212 | @apply w-full h-full object-cover;
213 | }
214 |
215 | /* 输入框统一样式优化 */
216 | .input,
217 | .select,
218 | .textarea {
219 | @apply bg-base-200/50 border-base-300/50 transition-all duration-200;
220 | }
221 |
222 | .input:focus,
223 | .select:focus,
224 | .textarea:focus {
225 | @apply bg-base-100 border-primary/50 outline-none ring-2 ring-primary/20;
226 | }
227 |
228 | .input::placeholder {
229 | @apply text-base-content/30;
230 | }
231 |
232 | /* 小尺寸输入框 */
233 | .input-sm,
234 | .select-sm {
235 | @apply h-8 min-h-8 text-sm px-3;
236 | }
237 |
238 | /* 去除 bordered 的粗边框,使用更细腻的样式 */
239 | .input-bordered,
240 | .select-bordered {
241 | @apply border border-base-300/60;
242 | }
243 |
244 | /* 暗色主题下的输入框 */
245 | [data-theme="dark"] .input,
246 | [data-theme="dark"] .select,
247 | [data-theme="dark"] .textarea {
248 | @apply bg-base-300/30 border-base-content/10;
249 | }
250 |
251 | [data-theme="dark"] .input:focus,
252 | [data-theme="dark"] .select:focus,
253 | [data-theme="dark"] .textarea:focus {
254 | @apply bg-base-300/50 border-primary/40;
255 | }
256 |
257 | /* 表单标签样式优化 */
258 | .label-text {
259 | @apply text-sm text-base-content/70;
260 | }
261 |
262 | .label-text-alt {
263 | @apply text-xs text-base-content/40;
264 | }
265 |
266 | /* 表单控件容器 */
267 | .form-control .label {
268 | @apply pb-1;
269 | }
270 |
271 | /* 下拉选择框箭头优化 */
272 | .select {
273 | @apply pr-8;
274 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
275 | background-position: right 0.5rem center;
276 | background-repeat: no-repeat;
277 | background-size: 1.25em 1.25em;
278 | }
279 |
280 | /* 下拉选项列表样式 */
281 | .select option {
282 | @apply bg-base-100 text-base-content;
283 | padding: 8px 12px;
284 | }
285 |
286 | .select option:hover,
287 | .select option:focus {
288 | @apply bg-primary text-primary-content;
289 | }
290 |
291 | .select option:checked {
292 | @apply bg-primary/20 text-primary;
293 | }
294 |
295 | [data-theme="dark"] .select option {
296 | @apply bg-base-300 text-base-content;
297 | }
298 |
299 | [data-theme="dark"] .select option:checked {
300 | @apply bg-primary/30 text-primary;
301 | }
302 |
303 | /* 加载指示器动画 - 避免使用 transform 旋转,防止字体模糊 */
304 | @keyframes pulse-dot {
305 | 0%, 100% {
306 | opacity: 0.3;
307 | }
308 | 50% {
309 | opacity: 1;
310 | }
311 | }
312 |
313 | @keyframes ping-slow {
314 | 0% {
315 | transform: scale(0.8);
316 | opacity: 0.8;
317 | }
318 | 100% {
319 | transform: scale(1.2);
320 | opacity: 0;
321 | }
322 | }
323 |
324 | @keyframes scale-bar {
325 | 0%, 100% {
326 | transform: scaleY(0.5);
327 | opacity: 0.5;
328 | }
329 | 50% {
330 | transform: scaleY(1);
331 | opacity: 1;
332 | }
333 | }
334 |
335 | /* 省略号加载动画 - 纯文字,不触发 GPU 合成 */
336 | .loading-dots::after {
337 | content: ".";
338 | animation: dots-step 1.5s steps(1) infinite;
339 | }
340 |
341 | @keyframes dots-step {
342 | 0% { content: "."; }
343 | 33% { content: ".."; }
344 | 66% { content: "..."; }
345 | }
346 |
--------------------------------------------------------------------------------
/src/services/imageService.ts:
--------------------------------------------------------------------------------
1 | import { GoogleGenAI } from "@google/genai";
2 | import { invoke } from "@tauri-apps/api/core";
3 | import type { ImageGenerationParams, ImageEditParams, GenerationResponse } from "@/types";
4 | import { useSettingsStore } from "@/stores/settingsStore";
5 |
6 | // 图片节点类型
7 | type ImageNodeType = "imageGeneratorPro" | "imageGeneratorFast";
8 |
9 | // 检测是否在 Tauri 环境中(Tauri 2.0)
10 | const isTauri = () => {
11 | const result = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
12 | console.log("[imageService] isTauri check:", result, "window keys:", typeof window !== "undefined" ? Object.keys(window).filter(k => k.includes("TAURI")) : []);
13 | return result;
14 | };
15 |
16 | // 获取供应商配置
17 | function getProviderConfig(nodeType: ImageNodeType) {
18 | const { settings } = useSettingsStore.getState();
19 | const providerId = settings.nodeProviders[nodeType];
20 |
21 | if (!providerId) {
22 | throw new Error("请先在供应商管理中配置此节点的供应商");
23 | }
24 |
25 | const provider = settings.providers.find((p) => p.id === providerId);
26 | if (!provider) {
27 | throw new Error("供应商不存在,请重新配置");
28 | }
29 |
30 | if (!provider.apiKey) {
31 | throw new Error("供应商 API Key 未配置");
32 | }
33 |
34 | return provider;
35 | }
36 |
37 | // 创建 API 客户端(仅用于 Web 环境)
38 | function createClient(nodeType: ImageNodeType) {
39 | const provider = getProviderConfig(nodeType);
40 |
41 | return new GoogleGenAI({
42 | apiKey: provider.apiKey,
43 | httpOptions: {
44 | baseUrl: provider.baseUrl,
45 | },
46 | });
47 | }
48 |
49 | // Tauri 后端代理请求参数
50 | interface TauriGeminiParams {
51 | baseUrl: string;
52 | apiKey: string;
53 | model: string;
54 | prompt: string;
55 | inputImages?: string[];
56 | aspectRatio?: string;
57 | imageSize?: string;
58 | }
59 |
60 | // Tauri 后端代理响应
61 | interface TauriGeminiResult {
62 | success: boolean;
63 | imageData?: string;
64 | text?: string;
65 | error?: string;
66 | }
67 |
68 | // 通过 Tauri 后端代理发送请求
69 | async function invokeGemini(params: TauriGeminiParams): Promise {
70 | console.log("[imageService] invokeGemini called, sending to Tauri backend...");
71 | console.log("[imageService] params:", { ...params, inputImages: params.inputImages?.length || 0, apiKey: "***" });
72 |
73 | try {
74 | const startTime = Date.now();
75 | const result = await invoke("gemini_generate_content", { params });
76 | const elapsed = Date.now() - startTime;
77 |
78 | console.log("[imageService] Tauri backend response received in", elapsed, "ms");
79 | console.log("[imageService] result:", { success: result.success, hasImage: !!result.imageData, error: result.error });
80 |
81 | if (!result.success) {
82 | return { error: result.error || "请求失败" };
83 | }
84 |
85 | return {
86 | imageData: result.imageData,
87 | text: result.text,
88 | };
89 | } catch (error) {
90 | console.error("[imageService] Tauri invoke error:", error);
91 | const message = error instanceof Error ? error.message : String(error);
92 | return { error: message };
93 | }
94 | }
95 |
96 | // 文本生成图片
97 | export async function generateImage(
98 | params: ImageGenerationParams,
99 | nodeType: ImageNodeType,
100 | abortSignal?: AbortSignal
101 | ): Promise {
102 | try {
103 | const provider = getProviderConfig(nodeType);
104 | const isPro = params.model === "gemini-3-pro-image-preview";
105 |
106 | // 在 Tauri 环境中使用后端代理
107 | if (isTauri()) {
108 | return await invokeGemini({
109 | baseUrl: provider.baseUrl || "https://generativelanguage.googleapis.com/v1beta",
110 | apiKey: provider.apiKey,
111 | model: params.model,
112 | prompt: params.prompt,
113 | aspectRatio: params.aspectRatio || "1:1",
114 | imageSize: isPro ? params.imageSize : undefined,
115 | });
116 | }
117 |
118 | // Web 环境使用 SDK
119 | const client = createClient(nodeType);
120 |
121 | const response = await client.models.generateContent({
122 | model: params.model,
123 | contents: [{ parts: [{ text: params.prompt }] }],
124 | config: {
125 | responseModalities: params.responseModalities || ["IMAGE"],
126 | imageConfig: {
127 | aspectRatio: params.aspectRatio || "1:1",
128 | ...(isPro && params.imageSize ? { imageSize: params.imageSize } : {}),
129 | },
130 | abortSignal,
131 | },
132 | });
133 |
134 | // 解析响应
135 | const candidate = response.candidates?.[0];
136 | if (!candidate?.content?.parts) {
137 | return { error: "无有效响应" };
138 | }
139 |
140 | let imageData: string | undefined;
141 | let text: string | undefined;
142 |
143 | for (const part of candidate.content.parts) {
144 | if (part.inlineData) {
145 | imageData = part.inlineData.data;
146 | } else if (part.text) {
147 | text = part.text;
148 | }
149 | }
150 |
151 | return { imageData, text };
152 | } catch (error) {
153 | // 检查是否是中断错误
154 | if (error instanceof Error && error.name === "AbortError") {
155 | return { error: "已取消" };
156 | }
157 | const message = error instanceof Error ? error.message : "生成失败";
158 | return { error: message };
159 | }
160 | }
161 |
162 | // 图片编辑(支持多图输入)
163 | export async function editImage(
164 | params: ImageEditParams,
165 | nodeType: ImageNodeType,
166 | abortSignal?: AbortSignal
167 | ): Promise {
168 | console.log("[imageService] editImage called, images count:", params.inputImages?.length || 0);
169 |
170 | try {
171 | const provider = getProviderConfig(nodeType);
172 | const isPro = params.model === "gemini-3-pro-image-preview";
173 |
174 | // 在 Tauri 环境中使用后端代理
175 | if (isTauri()) {
176 | console.log("[imageService] Using Tauri backend proxy");
177 | return await invokeGemini({
178 | baseUrl: provider.baseUrl || "https://generativelanguage.googleapis.com/v1beta",
179 | apiKey: provider.apiKey,
180 | model: params.model,
181 | prompt: params.prompt,
182 | inputImages: params.inputImages,
183 | aspectRatio: params.aspectRatio || "1:1",
184 | imageSize: isPro ? params.imageSize : undefined,
185 | });
186 | }
187 |
188 | console.log("[imageService] Using browser SDK (not Tauri)");
189 | // Web 环境使用 SDK
190 | const client = createClient(nodeType);
191 |
192 | const parts: Array<{ text: string } | { inlineData: { mimeType: string; data: string } }> = [
193 | { text: params.prompt },
194 | ];
195 |
196 | // 添加所有输入图片
197 | if (params.inputImages && params.inputImages.length > 0) {
198 | for (const imageData of params.inputImages) {
199 | parts.push({
200 | inlineData: {
201 | mimeType: "image/png",
202 | data: imageData,
203 | },
204 | });
205 | }
206 | }
207 |
208 | const response = await client.models.generateContent({
209 | model: params.model,
210 | contents: [{ parts }],
211 | config: {
212 | responseModalities: params.responseModalities || ["IMAGE"],
213 | imageConfig: {
214 | aspectRatio: params.aspectRatio || "1:1",
215 | ...(isPro && params.imageSize ? { imageSize: params.imageSize } : {}),
216 | },
217 | abortSignal,
218 | },
219 | });
220 |
221 | // 解析响应
222 | const candidate = response.candidates?.[0];
223 | if (!candidate?.content?.parts) {
224 | return { error: "无有效响应" };
225 | }
226 |
227 | let imageData: string | undefined;
228 | let text: string | undefined;
229 |
230 | for (const part of candidate.content.parts) {
231 | if (part.inlineData) {
232 | imageData = part.inlineData.data;
233 | } else if (part.text) {
234 | text = part.text;
235 | }
236 | }
237 |
238 | return { imageData, text };
239 | } catch (error) {
240 | // 检查是否是中断错误
241 | if (error instanceof Error && error.name === "AbortError") {
242 | return { error: "已取消" };
243 | }
244 | const message = error instanceof Error ? error.message : "编辑失败";
245 | return { error: message };
246 | }
247 | }
248 |
--------------------------------------------------------------------------------
/src/services/taskManager.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 全局任务管理器
3 | * 用于管理跨画布的异步任务(如视频生成、图片生成等)
4 | * 解决画布切换时轮询状态丢失的问题
5 | */
6 |
7 | import { useCanvasStore } from "@/stores/canvasStore";
8 | import { useFlowStore } from "@/stores/flowStore";
9 | import type { VideoGeneratorNodeData } from "@/types";
10 | import { pollVideoTask, type VideoProgressInfo } from "./videoService";
11 |
12 | // 任务状态
13 | export type TaskStatus = "running" | "completed" | "failed" | "cancelled";
14 |
15 | // 任务信息
16 | export interface TaskInfo {
17 | taskId: string;
18 | nodeId: string;
19 | canvasId: string;
20 | type: "video" | "image";
21 | status: TaskStatus;
22 | progress: number;
23 | stage?: string;
24 | error?: string;
25 | startTime: number;
26 | }
27 |
28 | // 任务管理器类
29 | class TaskManager {
30 | // 存储所有正在进行的任务
31 | private tasks: Map = new Map();
32 | // 存储任务的取消函数
33 | private abortControllers: Map = new Map();
34 |
35 | /**
36 | * 注册一个视频生成任务
37 | */
38 | registerVideoTask(
39 | taskId: string,
40 | nodeId: string,
41 | canvasId: string
42 | ): void {
43 | const taskKey = this.getTaskKey(nodeId, canvasId);
44 |
45 | // 如果已有相同的任务,先取消
46 | this.cancelTask(nodeId, canvasId);
47 |
48 | const taskInfo: TaskInfo = {
49 | taskId,
50 | nodeId,
51 | canvasId,
52 | type: "video",
53 | status: "running",
54 | progress: 0,
55 | stage: "queued",
56 | startTime: Date.now(),
57 | };
58 |
59 | this.tasks.set(taskKey, taskInfo);
60 |
61 | // 创建 AbortController
62 | const abortController = new AbortController();
63 | this.abortControllers.set(taskKey, abortController);
64 |
65 | // 开始轮询
66 | this.startPolling(taskKey, taskId, abortController.signal);
67 | }
68 |
69 | /**
70 | * 开始轮询任务状态
71 | */
72 | private async startPolling(
73 | taskKey: string,
74 | taskId: string,
75 | signal: AbortSignal
76 | ): Promise {
77 | const task = this.tasks.get(taskKey);
78 | if (!task) return;
79 |
80 | try {
81 | await pollVideoTask(
82 | taskId,
83 | (info: VideoProgressInfo) => {
84 | // 检查是否已取消
85 | if (signal.aborted) return;
86 |
87 | // 更新任务信息
88 | this.updateTask(taskKey, {
89 | progress: info.progress,
90 | stage: info.stage,
91 | status: info.stage === "completed" ? "completed" :
92 | info.stage === "failed" ? "failed" : "running",
93 | });
94 |
95 | // 同步更新到节点数据(无论当前是否在该画布)
96 | this.syncToNode(taskKey, info);
97 | }
98 | );
99 |
100 | // 轮询完成后更新状态
101 | if (!signal.aborted) {
102 | const updatedTask = this.tasks.get(taskKey);
103 | if (updatedTask && updatedTask.status === "running") {
104 | this.updateTask(taskKey, { status: "completed", progress: 100 });
105 | }
106 | }
107 | } catch (error) {
108 | if (!signal.aborted) {
109 | const errorMessage = error instanceof Error ? error.message : "任务失败";
110 | this.updateTask(taskKey, { status: "failed", error: errorMessage });
111 |
112 | // 同步错误状态到节点
113 | const task = this.tasks.get(taskKey);
114 | if (task) {
115 | this.syncErrorToNode(task, errorMessage);
116 | }
117 | }
118 | }
119 | }
120 |
121 | /**
122 | * 同步进度到节点数据
123 | */
124 | private syncToNode(taskKey: string, info: VideoProgressInfo): void {
125 | const task = this.tasks.get(taskKey);
126 | if (!task) return;
127 |
128 | const { activeCanvasId } = useCanvasStore.getState();
129 | const { updateNodeData } = useFlowStore.getState();
130 |
131 | // 如果当前在该任务所属的画布,直接更新 flowStore
132 | if (activeCanvasId === task.canvasId) {
133 | updateNodeData(task.nodeId, {
134 | progress: info.progress,
135 | taskStage: info.stage,
136 | taskId: info.taskId,
137 | status: info.stage === "completed" ? "success" :
138 | info.stage === "failed" ? "error" : "loading",
139 | });
140 | }
141 |
142 | // 同时更新 canvasStore 中的数据(确保切换回来时数据正确)
143 | this.updateCanvasNodeData(task.canvasId, task.nodeId, {
144 | progress: info.progress,
145 | taskStage: info.stage,
146 | taskId: info.taskId,
147 | status: info.stage === "completed" ? "success" :
148 | info.stage === "failed" ? "error" : "loading",
149 | });
150 | }
151 |
152 | /**
153 | * 同步错误状态到节点
154 | */
155 | private syncErrorToNode(task: TaskInfo, error: string): void {
156 | const { activeCanvasId } = useCanvasStore.getState();
157 | const { updateNodeData } = useFlowStore.getState();
158 |
159 | if (activeCanvasId === task.canvasId) {
160 | updateNodeData(task.nodeId, {
161 | status: "error",
162 | error,
163 | taskStage: "failed",
164 | });
165 | }
166 |
167 | this.updateCanvasNodeData(task.canvasId, task.nodeId, {
168 | status: "error",
169 | error,
170 | taskStage: "failed",
171 | });
172 | }
173 |
174 | /**
175 | * 更新 canvasStore 中特定画布的节点数据
176 | */
177 | private updateCanvasNodeData(
178 | canvasId: string,
179 | nodeId: string,
180 | data: Partial
181 | ): void {
182 | const canvasStore = useCanvasStore.getState();
183 | const canvas = canvasStore.canvases.find(c => c.id === canvasId);
184 |
185 | if (!canvas) return;
186 |
187 | const updatedNodes = canvas.nodes.map(node => {
188 | if (node.id === nodeId) {
189 | return {
190 | ...node,
191 | data: { ...node.data, ...data },
192 | };
193 | }
194 | return node;
195 | });
196 |
197 | // 直接更新 canvasStore 中的画布数据
198 | useCanvasStore.setState(state => ({
199 | canvases: state.canvases.map(c =>
200 | c.id === canvasId
201 | ? { ...c, nodes: updatedNodes, updatedAt: Date.now() }
202 | : c
203 | ),
204 | }));
205 | }
206 |
207 | /**
208 | * 更新任务信息
209 | */
210 | private updateTask(taskKey: string, updates: Partial): void {
211 | const task = this.tasks.get(taskKey);
212 | if (task) {
213 | this.tasks.set(taskKey, { ...task, ...updates });
214 | }
215 | }
216 |
217 | /**
218 | * 取消任务
219 | */
220 | cancelTask(nodeId: string, canvasId: string): void {
221 | const taskKey = this.getTaskKey(nodeId, canvasId);
222 |
223 | const abortController = this.abortControllers.get(taskKey);
224 | if (abortController) {
225 | abortController.abort();
226 | this.abortControllers.delete(taskKey);
227 | }
228 |
229 | const task = this.tasks.get(taskKey);
230 | if (task) {
231 | this.updateTask(taskKey, { status: "cancelled" });
232 | this.tasks.delete(taskKey);
233 | }
234 | }
235 |
236 | /**
237 | * 获取任务信息
238 | */
239 | getTask(nodeId: string, canvasId: string): TaskInfo | undefined {
240 | const taskKey = this.getTaskKey(nodeId, canvasId);
241 | return this.tasks.get(taskKey);
242 | }
243 |
244 | /**
245 | * 检查节点是否有正在运行的任务
246 | */
247 | isTaskRunning(nodeId: string, canvasId: string): boolean {
248 | const task = this.getTask(nodeId, canvasId);
249 | return task?.status === "running";
250 | }
251 |
252 | /**
253 | * 获取所有任务
254 | */
255 | getAllTasks(): TaskInfo[] {
256 | return Array.from(this.tasks.values());
257 | }
258 |
259 | /**
260 | * 获取指定画布的所有任务
261 | */
262 | getTasksByCanvas(canvasId: string): TaskInfo[] {
263 | return Array.from(this.tasks.values()).filter(t => t.canvasId === canvasId);
264 | }
265 |
266 | /**
267 | * 生成任务唯一键
268 | */
269 | private getTaskKey(nodeId: string, canvasId: string): string {
270 | return `${canvasId}:${nodeId}`;
271 | }
272 |
273 | /**
274 | * 清理已完成/失败的任务
275 | */
276 | cleanupCompletedTasks(): void {
277 | for (const [key, task] of this.tasks.entries()) {
278 | if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") {
279 | this.tasks.delete(key);
280 | this.abortControllers.delete(key);
281 | }
282 | }
283 | }
284 | }
285 |
286 | // 导出单例
287 | export const taskManager = new TaskManager();
288 |
--------------------------------------------------------------------------------
/src/utils/connectionValidator.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * 连接验证工具
3 | * 用于验证节点之间的连接是否合法
4 | */
5 |
6 | import type { Edge, Node, Connection } from "@xyflow/react";
7 | import { nodeCategories } from "@/config/nodeConfig";
8 |
9 | // Handle 类型定义
10 | export type HandleType = "prompt" | "image" | "video" | "data" | "results" | "text" | "file";
11 |
12 | // 节点的输入输出配置映射
13 | interface NodeIOConfig {
14 | inputs: HandleType[];
15 | outputs: HandleType[];
16 | }
17 |
18 | // 从 nodeConfig 构建节点IO配置映射
19 | const nodeIOConfigMap: Record = {};
20 |
21 | // 初始化配置映射
22 | nodeCategories.forEach((category) => {
23 | category.nodes.forEach((nodeDef) => {
24 | nodeIOConfigMap[nodeDef.type] = {
25 | inputs: (nodeDef.inputs || []) as HandleType[],
26 | outputs: (nodeDef.outputs || []) as HandleType[],
27 | };
28 | });
29 | });
30 |
31 | /**
32 | * 获取节点的IO配置
33 | */
34 | export function getNodeIOConfig(nodeType: string): NodeIOConfig | undefined {
35 | return nodeIOConfigMap[nodeType];
36 | }
37 |
38 | /**
39 | * 获取节点的输出类型
40 | */
41 | export function getNodeOutputType(nodeType: string): HandleType | undefined {
42 | const config = nodeIOConfigMap[nodeType];
43 | return config?.outputs[0]; // 目前每个节点只有一个输出类型
44 | }
45 |
46 | /**
47 | * 获取节点的输入类型列表
48 | */
49 | export function getNodeInputTypes(nodeType: string): HandleType[] {
50 | const config = nodeIOConfigMap[nodeType];
51 | return config?.inputs || [];
52 | }
53 |
54 | /**
55 | * 检查类型是否兼容
56 | * prompt 只能连 prompt 输入
57 | * image 可以连 image 输入
58 | * video 可以连 video 输入(如果未来有的话)
59 | */
60 | export function areTypesCompatible(
61 | sourceType: HandleType,
62 | targetInputTypes: HandleType[]
63 | ): boolean {
64 | return targetInputTypes.includes(sourceType);
65 | }
66 |
67 | /**
68 | * 检测是否存在循环引用
69 | * 使用 DFS 检测从 targetId 是否可以到达 sourceId
70 | */
71 | export function wouldCreateCycle(
72 | edges: Edge[],
73 | sourceId: string,
74 | targetId: string
75 | ): boolean {
76 | // 构建邻接表(从 target 到 source 的反向图)
77 | const reverseGraph = new Map();
78 |
79 | edges.forEach((edge) => {
80 | if (!reverseGraph.has(edge.target)) {
81 | reverseGraph.set(edge.target, []);
82 | }
83 | reverseGraph.get(edge.target)!.push(edge.source);
84 | });
85 |
86 | // 从 sourceId 开始 DFS,看是否能到达 targetId
87 | const visited = new Set();
88 | const stack = [sourceId];
89 |
90 | while (stack.length > 0) {
91 | const current = stack.pop()!;
92 | if (current === targetId) {
93 | return true; // 会形成循环
94 | }
95 | if (visited.has(current)) {
96 | continue;
97 | }
98 | visited.add(current);
99 |
100 | const predecessors = reverseGraph.get(current) || [];
101 | for (const pred of predecessors) {
102 | if (!visited.has(pred)) {
103 | stack.push(pred);
104 | }
105 | }
106 | }
107 |
108 | return false;
109 | }
110 |
111 | /**
112 | * 检查是否已存在相同的连接
113 | */
114 | export function connectionExists(
115 | edges: Edge[],
116 | sourceId: string,
117 | targetId: string,
118 | sourceHandle?: string | null,
119 | targetHandle?: string | null
120 | ): boolean {
121 | return edges.some(
122 | (edge) =>
123 | edge.source === sourceId &&
124 | edge.target === targetId &&
125 | edge.sourceHandle === sourceHandle &&
126 | edge.targetHandle === targetHandle
127 | );
128 | }
129 |
130 | /**
131 | * 检查目标 Handle 是否已有连接(单输入限制)
132 | * @param edges 现有边列表
133 | * @param targetId 目标节点ID
134 | * @param targetHandle Handle ID(用于区分不同输入端口)
135 | * @returns 是否已有连接
136 | */
137 | export function targetHandleHasConnection(
138 | edges: Edge[],
139 | targetId: string,
140 | targetHandle?: string | null
141 | ): boolean {
142 | return edges.some(
143 | (edge) =>
144 | edge.target === targetId && edge.targetHandle === targetHandle
145 | );
146 | }
147 |
148 | /**
149 | * 获取连接到目标节点特定 Handle 的现有边
150 | */
151 | export function getExistingConnectionToHandle(
152 | edges: Edge[],
153 | targetId: string,
154 | targetHandle?: string | null
155 | ): Edge | undefined {
156 | return edges.find(
157 | (edge) =>
158 | edge.target === targetId && edge.targetHandle === targetHandle
159 | );
160 | }
161 |
162 | // 连接验证结果
163 | export interface ConnectionValidationResult {
164 | isValid: boolean;
165 | reason?: string;
166 | existingEdge?: Edge; // 如果存在已有连接,返回该边(用于替换)
167 | }
168 |
169 | /**
170 | * 综合验证连接是否合法
171 | */
172 | export function validateConnection(
173 | connection: Connection,
174 | nodes: Node[],
175 | edges: Edge[]
176 | ): ConnectionValidationResult {
177 | const { source, target, sourceHandle, targetHandle } = connection;
178 |
179 | // 1. 基本检查:不能自连接
180 | if (source === target) {
181 | return { isValid: false, reason: "不能连接到自身" };
182 | }
183 |
184 | // 2. 找到源节点和目标节点
185 | const sourceNode = nodes.find((n) => n.id === source);
186 | const targetNode = nodes.find((n) => n.id === target);
187 |
188 | if (!sourceNode || !targetNode) {
189 | return { isValid: false, reason: "节点不存在" };
190 | }
191 |
192 | // 3. 获取源节点的输出类型
193 | const sourceOutputType = getNodeOutputType(sourceNode.type || "");
194 | if (!sourceOutputType) {
195 | return { isValid: false, reason: "源节点没有输出" };
196 | }
197 |
198 | // 4. 获取目标节点的输入类型
199 | const targetInputTypes = getNodeInputTypes(targetNode.type || "");
200 | if (targetInputTypes.length === 0) {
201 | return { isValid: false, reason: "目标节点没有输入" };
202 | }
203 |
204 | // 5. 检查类型兼容性
205 | // 如果有 targetHandle,用它来确定期望的输入类型
206 | // 否则检查源类型是否在目标接受的类型中
207 | if (targetHandle) {
208 | // targetHandle 格式: "input-prompt" 或 "input-image"
209 | const expectedType = targetHandle.replace("input-", "") as HandleType;
210 | if (sourceOutputType !== expectedType) {
211 | return {
212 | isValid: false,
213 | reason: `类型不匹配:${sourceOutputType} → ${expectedType}`,
214 | };
215 | }
216 | } else {
217 | // 没有指定 handle,检查是否兼容
218 | if (!areTypesCompatible(sourceOutputType, targetInputTypes)) {
219 | return {
220 | isValid: false,
221 | reason: `类型不匹配:${sourceOutputType} 不能连接到 ${targetInputTypes.join("/")} 输入`,
222 | };
223 | }
224 | }
225 |
226 | // 6. 检查循环引用
227 | if (wouldCreateCycle(edges, source!, target!)) {
228 | return { isValid: false, reason: "不能创建循环连接" };
229 | }
230 |
231 | // 7. 检查重复连接
232 | if (connectionExists(edges, source!, target!, sourceHandle, targetHandle)) {
233 | return { isValid: false, reason: "连接已存在" };
234 | }
235 |
236 | // 8. 检查单输入限制
237 | // - prompt 输入: 只允许一个连接
238 | // - image 输入: ImageGenerator 和 PPTContent 允许多个,VideoGenerator 只允许一个
239 | const isMultiImageAllowed =
240 | targetNode.type === "imageGeneratorProNode" ||
241 | targetNode.type === "imageGeneratorFastNode" ||
242 | targetNode.type === "pptContentNode";
243 |
244 | const isImageInput = targetHandle === "input-image" ||
245 | (!targetHandle && sourceOutputType === "image");
246 |
247 | // 如果是允许多图的节点的 image 输入,直接允许连接
248 | if (isMultiImageAllowed && isImageInput) {
249 | return { isValid: true };
250 | }
251 |
252 | // 其他情况:检查是否需要替换现有连接
253 | const existingEdge = getExistingConnectionToHandle(
254 | edges,
255 | target!,
256 | targetHandle
257 | );
258 |
259 | if (existingEdge) {
260 | // 返回已存在的边,让调用者决定是否替换
261 | return {
262 | isValid: true,
263 | reason: "将替换现有连接",
264 | existingEdge,
265 | };
266 | }
267 |
268 | // 对于没有 targetHandle 的情况,检查是否已有同类型的输入
269 | if (!targetHandle) {
270 | const existingSameTypeConnection = edges.find((edge) => {
271 | if (edge.target !== target) return false;
272 | const edgeSourceNode = nodes.find((n) => n.id === edge.source);
273 | if (!edgeSourceNode) return false;
274 | const edgeSourceType = getNodeOutputType(edgeSourceNode.type || "");
275 | return edgeSourceType === sourceOutputType;
276 | });
277 |
278 | if (existingSameTypeConnection) {
279 | return {
280 | isValid: true,
281 | reason: "将替换现有连接",
282 | existingEdge: existingSameTypeConnection,
283 | };
284 | }
285 | }
286 |
287 | return { isValid: true };
288 | }
289 |
290 | /**
291 | * 用于 React Flow 的 isValidConnection 回调
292 | * 这是一个简化版本,用于连接预览时的快速验证
293 | */
294 | export function createIsValidConnection(nodes: Node[], edges: Edge[]) {
295 | return (connection: Connection): boolean => {
296 | const result = validateConnection(connection, nodes, edges);
297 | return result.isValid;
298 | };
299 | }
300 |
--------------------------------------------------------------------------------