diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 496b5c8..a783b40 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -33,7 +33,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: './backend/go.mod' cache-dependency-path: './backend/go.sum' # Go 代码格式检查 @@ -127,7 +127,7 @@ jobs: npm run build # ======================================== - # Job 3: 烟雾测试(可选) + # Job 3: 烟雾测试 # ======================================== smoke-test: name: Smoke Test @@ -142,7 +142,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: './backend/go.mod' # 启动后端并验证健康检查 - name: Start backend and health check @@ -154,9 +154,14 @@ jobs: cp .env.example .env fi + # 先编译,避免首次 go run 下载/编译依赖占用健康检查等待时间 + echo "编译后端服务..." + go build -o /tmp/banana-pro-server ./cmd/server/main.go + # 启动后端(后台运行) echo "启动后端服务..." - go run cmd/server/main.go & + LOG_FILE=/tmp/banana-pro-server.log + DISABLE_STDIN_MONITOR=1 /tmp/banana-pro-server > "$LOG_FILE" 2>&1 & SERVER_PID=$! # 等待后端启动(最多30秒) @@ -169,10 +174,15 @@ jobs: exit 1 fi - # 检查健康检查接口(假设有 /health 或 /api/health) - if curl -sf http://localhost:8080/health > /dev/null 2>&1 || \ - curl -sf http://localhost:8080/api/health > /dev/null 2>&1; then - echo "✅ 后端启动成功(${i}秒)" + HEALTH_PORT=$(grep -m1 -oE 'SERVER_PORT=[0-9]+' "$LOG_FILE" | cut -d= -f2 || true) + if [ -z "$HEALTH_PORT" ]; then + sleep 1 + continue + fi + + # 检查标准健康检查接口 + if curl -sf "http://localhost:${HEALTH_PORT}/api/v1/health" > /dev/null 2>&1; then + echo "✅ 后端启动成功(${i}秒,端口 ${HEALTH_PORT})" # 测试其他关键接口(可选) # curl -sf http://localhost:8080/api/status @@ -187,9 +197,9 @@ jobs: # 超时 echo "❌ 后端启动超时(${MAX_WAIT}秒)" + cat "$LOG_FILE" || true kill $SERVER_PID 2>/dev/null || true exit 1 - continue-on-error: true # ======================================== # Job 4: PR 总结(可选) @@ -197,7 +207,7 @@ jobs: pr-summary: name: PR Summary runs-on: ubuntu-latest - needs: [backend-check, frontend-check] + needs: [backend-check, frontend-check, smoke-test] if: always() steps: @@ -224,7 +234,7 @@ jobs: | **前端检查** (React) | ${statusEmoji[frontend] || '❓'} ${frontend} | | **烟雾测试** | ${statusEmoji[smoke] || '❓'} ${smoke} | - ${backend === 'success' && frontend === 'success' ? '### ✅ 所有检查通过,可以合并!' : '### ⚠️ 请修复上述问题后再合并'} + ${backend === 'success' && frontend === 'success' && smoke === 'success' ? '### ✅ 所有检查通过,可以合并!' : '### ⚠️ 请修复上述问题后再合并'} --- *由 GitHub Actions 自动生成* diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2991478..a10e1be 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,7 +18,7 @@ jobs: - name: 安装 Go 环境 uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: './backend/go.mod' cache-dependency-path: './backend/go.sum' - name: 后端代码检查 @@ -119,7 +119,7 @@ jobs: - name: 安装 Go 环境 uses: actions/setup-go@v5 with: - go-version: '1.21' + go-version-file: './backend/go.mod' cache-dependency-path: './backend/go.sum' - name: 生成 Release Notes @@ -148,7 +148,7 @@ jobs: fi - name: 安装前端依赖 - run: npm install + run: npm ci working-directory: ./desktop - name: 导入 Apple 签名证书 (macOS) diff --git a/.gitignore b/.gitignore index 030cf6f..27c64c1 100644 --- a/.gitignore +++ b/.gitignore @@ -109,3 +109,4 @@ templates_nano_banana_pro.json TEMPLATE_MARKET_PLAN.md .sisyphus/ .codex/ +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 87b66a9..a480582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,7 @@ ```bash # 后端 cd backend && go run cmd/server/main.go # 启动后端 (默认 :8080) +curl http://localhost:8080/api/v1/health # 标准健康检查接口 make build # 编译 make run # 运行 @@ -27,13 +28,15 @@ cd desktop && npm install && npm run tauri dev cd frontend && npm install && npm run dev # 打包发布 -cd desktop && npm run tauri build # 本地构建桌面安装包 +cd desktop && npm run tauri:build:local # 本地构建桌面安装包(先生成 Go sidecar,跳过 updater 签名) # Docker 部署 (Web 版) docker compose -p banana-pro up -d # 发布流程 git tag v2.8.0 && git push origin v2.8.0 # 触发 GitHub Actions 自动构建 +# Release CI +cd desktop && npm ci # 发布构建使用 lockfile 严格安装依赖 ``` ## 项目结构 @@ -60,7 +63,7 @@ backend/ # Go 后端 (Sidecar) desktop/ # Tauri 桌面端 ├── src/ -│ ├── components/ # 37 React 组件 +│ ├── components/ # 38 React 组件 │ ├── store/ # 8 个 Zustand Store (configStore migration v19) │ ├── hooks/ # 6 个自定义 Hook │ ├── services/ # 8 个 API 服务文件 @@ -72,7 +75,7 @@ desktop/ # Tauri 桌面端 │ └── capabilities/ # Tauri 权限声明 └── package.json -frontend/ # 独立 Web 前端 (v2.5.2, 非 Tauri) +frontend/ # 独立 Web 前端 (非 Tauri,Docker 构建入口;版本跟随 desktop/package.json) ``` ## 技术栈 @@ -103,10 +106,14 @@ frontend/ # 独立 Web 前端 (v2.5.2, 非 Tauri) 1. **asset:// 协议**:桌面端注册原生资源协议,绕过 HTTP 栈加载本地图片,速度提升 300% 2. **Sidecar 模式**:Go 后端作为 Tauri sidecar 运行,Tauri 退出时自动清理进程 -3. **Worker 池**:6 workers + 100-slot 队列,per-provider 超时,panic 自动恢复 +3. **Worker 池**:6 workers + 100-slot 队列,per-provider 超时,provider 调用在 worker goroutine 内执行并带 panic 自动恢复 4. **IPC 优化**:前后端只传文件路径,二进制数据通过 asset:// 协议直读 5. **Prompt 优化**:singleflight 去重 + 10min 缓存,支持 text/json 两种输出模式 -6. **模板市场**:内嵌 JSON + 远程 GitHub Raw + 本地缓存三层策略,24h 自动刷新 +6. **模板市场**:内嵌 JSON + 远程 GitHub Raw + 本地缓存三层策略,24h 自动刷新;桌面端模板网格使用 `react-window` 虚拟化渲染,避免 935+ 模板一次性挂载 +7. **服务端连接超时**:Go HTTP Server 使用 5s ReadHeaderTimeout、30s ReadTimeout、120s IdleTimeout;WriteTimeout 保持 0,避免截断任务状态 SSE 长连接 +8. **Docker Nginx 长连接代理**:Web 版 Nginx 在 `http` 上下文使用 `$http_upgrade` 到 `$connection_upgrade` 的 `map`,API 代理只设置一个 `Connection $connection_upgrade`,兼容普通 HTTP 请求和 WebSocket/SSE 升级场景 +9. **桌面端语言包加载**:`desktop/src/i18n/index.ts` 只在启动资源中内置默认 `zh-CN`,`en-US` / `ja-JP` / `ko-KR` 通过动态 import 在切换语言前按需加载,避免启动时打包并解析全部 locale JSON +10. **Tauri asset/CSP 边界**:桌面端 `asset://` 仅允许应用数据、应用配置、应用缓存和临时目录范围,禁止回退到 `$HOME/**`;CSP 只放行本地应用资源、localhost API/SSE、`asset/blob/data/http(s)` 图片与必要的 Vite/Tauri 内联样式 ## 代码风格 @@ -120,34 +127,55 @@ frontend/ # 独立 Web 前端 (v2.5.2, 非 Tauri) ### React 前端 - 函数组件 + Hooks,无 Class 组件 - Zustand 状态管理,`configStore` 管理 provider 配置和迁移 -- API 调用集中在 `services/` 目录 +- API 调用集中在 `services/` 目录,组件通过 service 函数 + Zustand/本地 state 管理请求结果;当前不使用 React Query,重新引入前必须先确定查询缓存边界和迁移入口 - i18n 使用 `react-i18next`,翻译文件在 `i18n/locales/` - Tailwind CSS 样式 ### 通用 - 中文注释解释「为什么」而非「做什么」 - 提交信息格式:`feat:`, `fix:`, `chore:`, `docs:` -- 版本号统一在 `Cargo.toml`, `tauri.conf.json`, `package.json`, `Cargo.lock`, `package-lock.json` +- 桌面发布版本号统一在 `Cargo.toml`, `tauri.conf.json`, `desktop/package.json`, `Cargo.lock`, `desktop/package-lock.json` +- Docker Web 版仍构建 `frontend/`,`frontend/package.json` 与 `frontend/package-lock.json` 的根版本必须跟随 `desktop/package.json`,避免 Web 镜像与当前应用版本元数据脱节 ## 注意事项 & 易错点 -1. **端口冲突**:Go sidecar 监听 `127.0.0.1:8080`,确保无其他进程占用。调试时检查 `lsof -i :8080` +1. **端口冲突**:Go sidecar 监听 `127.0.0.1:8080`,标准健康检查接口为 `GET /api/v1/health`;确保无其他进程占用。调试时检查 `lsof -i :8080` 2. **模型名称**:`openai-image` provider 默认模型是 `gpt-image-2`(v2.8.0 更新),不支持 `quality` 参数 -3. **版本同步**:发布前确保 5 个文件的版本号一致(见上方「通用」部分) +3. **版本同步**:发布前确保桌面版本文件一致,并同步 `frontend/package.json` / `frontend/package-lock.json` 根版本;Dockerfile 当前构建的是 `frontend/`,不要让 Web 镜像保留旧版本号 4. **Tauri 权限**:新功能涉及文件系统/网络访问时需更新 `capabilities/default.json` 5. **Docker vs Desktop**:后端通过 `platform/runtime.go` 检测运行环境,Docker 监听 `0.0.0.0`,Tauri 监听 `127.0.0.1` 6. **configStore 迁移**:前端配置存储在 localStorage,版本迁移在 `configStore.ts` 的 `migrations` 中处理,当前版本 v19 7. **图片存储路径**:桌面端默认 `~/Library/Application Support/com.banana.pro/` (macOS),`%APPDATA%/com.banana.pro/` (Windows) +8. **参考图边界**:后端必须同时限制 multipart `refImages` 与桌面端本地 `refPaths`,最多 10 张、单张最多 20MB、总计最多 80MB;本地路径读取必须先 `os.Open`,对已打开文件执行 `file.Stat` 并校验普通文件/大小/总量,再通过有界读取与关闭 helper 读取,禁止回退到裸 `os.ReadFile` +9. **Provider 诊断日志**:OpenAI、Gemini、OpenAI Image 的响应日志和错误返回默认只能记录状态、耗时、请求 ID、响应长度和有界脱敏预览,禁止输出完整响应体、未脱敏错误体或完整 base64 图片数据 +10. **HTTP Server 超时**:新增或修改后端 server 构造时必须保留 `ReadHeaderTimeout=5s`、`ReadTimeout=30s`、`IdleTimeout=120s`;不要给全局 `WriteTimeout` 设置短超时,因为 `/api/v1/tasks/:task_id/stream` 依赖 SSE 长连接保活 +11. **Worker Provider 超时**:新增或修改 provider 时必须把传入的 `context.Context` 继续传递给 HTTP 请求/长耗时操作并及时返回;Worker 不再为 `Generate` 额外派生 goroutine,超时后会在 provider 返回时记录 `生成超时(...)`,provider panic 会转换为任务失败;provider 内部如需 `io.Pipe`/multipart writer goroutine,也必须监听同一个 context 并在取消时关闭管道 +12. **模板市场渲染**:`desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx` 的模板列表必须保持响应式虚拟网格(2/3/4 列),只渲染可见 `TemplateCard`;模板市场内容区必须保持单一纵向滚动容器,筛选器随模板列表一起滚走;虚拟网格只渲染可见卡片,不得把筛选器固定在虚拟列表外部;列数和断点只能通过文件顶部 `TEMPLATE_GRID_COLUMNS` / `TEMPLATE_GRID_BREAKPOINTS` 常量调整,不要在计算函数里散落魔法数字;修改搜索、筛选、预览或应用逻辑时不得回退为 `filteredTemplates.map(...)` 全量渲染 +13. **图片 URL 诊断日志**:`desktop/src/services/api.ts` 的 `getImageUrl` 属于批量图片渲染热路径,URL 转换/回退日志必须通过 `getDiagnosticVerbose()` 或等价诊断开关门控;默认不得在控制台输出每张图片的 URL 生成日志 +14. **Zustand 订阅范围**:桌面端组件不得直接调用 `useConfigStore()`、`useGenerateStore()`、`useToastStore()` 等整仓订阅;只读取单字段时使用 selector,读取多个字段/action 时使用 `useShallow` 包裹对象 selector,避免无关状态变化触发重渲染,同时不得改变 store state shape 或 persistence 行为 +15. **历史缓存持久化**:`desktop/src/store/historyStore.ts` 的 `history-cache` localStorage 只允许保存轻量、有界的历史列表快照(当前最多 20 条)和分页元信息;持久化与旧缓存合并必须剥离 `url` / `thumbnailUrl` 等派生展示 URL,避免缓存膨胀,并确保 `hasMore` / `page` 仍能从下一页继续加载 +16. **Nginx Upgrade 头**:修改 `docker/nginx.conf` 的 `/api/` 代理时不得同时设置多个 `proxy_set_header Connection`;必须保留 `proxy_http_version 1.1`、`Upgrade $http_upgrade`、`Connection $connection_upgrade`、现有 300s 读写超时,以及 `http` 级 `map $http_upgrade $connection_upgrade`,避免普通 API 请求被错误标记为 upgrade 或覆盖升级头 +17. **Docker 健康检查覆盖范围**:Dockerfile 与 `docker-compose.yml` 的主容器健康检查必须保持一致,并通过 Nginx 同时验证前端入口 `/` 和后端 API `GET /api/v1/health`;不要只检查直连后端 `:8080`,否则无法发现 Nginx/静态前端不可用的问题 +18. **Provider 配置 API 入口**:桌面端 `ProviderConfig` 类型和 `updateProviderConfig` 实现由 `desktop/src/services/providerApi.ts` 统一维护;`configApi.ts` 只能为兼容旧调用重导出这些符号或保留独立的旧接口包装,禁止再次复制 provider 配置类型或 `/providers/config` 更新逻辑 +19. **模板市场组件边界**:`TemplateMarketDrawer.tsx` 负责抽屉生命周期、数据加载、过滤状态、预览和应用模板;搜索框、筛选 chip、分类筛选和刷新/数量栏由 `desktop/src/components/TemplateMarket/TemplateMarketFilters.tsx` 维护。继续拆分模板市场时一次只抽一个清晰职责,不得改变现有 Tailwind 样式、虚拟网格、搜索/筛选、预览或应用行为 +20. **参考图上传逻辑边界**:`ReferenceImageUpload.tsx` 负责参考图区域 UI、点击/拖拽添加、压缩、持久化、预览和排序;剪贴板图片提取、Tauri 剪贴板兜底、全局 paste 捕获由同目录 `useReferenceImagePaste.ts` 维护。修改粘贴能力时优先调整该 hook,并保持现有 add/delete/drag/paste 行为和 Tailwind 样式不变 + - 参考图拖拽的临时 Blob 缓存只能通过 `window.__BANANA_DRAG_IMAGE_DATA__` 这个固定字段读写,并配合 `isCachedDragImageData` 运行时守卫;不要重新使用 `window[symbol]` 或其他动态全局索引,避免静态扫描误判对象注入 +21. **设置弹窗字段组边界**:`SettingsModal.tsx` 继续负责标签页状态、provider 切换、保存、测试连接和跨 section 草稿状态;provider 连接字段(Base URL、API Key、显隐按钮、云雾推荐入口和警告/提示插槽)由 `desktop/src/components/Settings/ProviderConnectionFields.tsx` 维护。继续拆分设置弹窗时一次只抽一个清晰 section 或 field group,并保持现有保存/测试/切换行为和 Tailwind 样式不变 +22. **桌面端数据请求策略**:桌面端当前没有 `useQuery` / `useMutation` 调用,也不挂载 `QueryClientProvider`;所有请求仍从 `desktop/src/services/` 的显式 API helper 发起,再由 Zustand store 或组件本地 state 保存结果。未来如要引入 React Query,必须从具体 service 调用的最小迁移点开始,并在同一变更中说明缓存 key、失效策略和与 Zustand 的职责边界,禁止只添加全局 Provider 或空依赖 +23. **桌面端 i18n 语言包**:新增或修改桌面端语言切换逻辑时,必须通过 `changeAppLanguage` 先加载目标语言资源再调用 i18next 切换;不要在 `desktop/src/i18n/index.ts` 静态 import 所有 locale JSON。默认语言 `zh-CN` 随启动加载,`en-US`、`ja-JP`、`ko-KR` 保持可用并在首次切换时懒加载,加载期间继续显示当前语言以减少闪烁 +24. **Tauri asset/CSP 安全边界**:`desktop/src-tauri/tauri.conf.json` 的 `assetProtocol.scope` 必须保持在 `$APPDATA/**`、`$APPCONFIG/**`、`$APPCACHE/**`、`$TEMP/**` 等应用/临时目录内,禁止重新加入 `$HOME/**`。CSP 不得设回 `null`;新增图片、Worker、网络或内联资源需求时,只能按实际来源最小化追加 `img-src` / `connect-src` / `worker-src` / `style-src`,并确认 `desktop/src/services/api.ts` 的 `convertFileSrc(..., 'asset')` 仍能加载 `storage/` 生成图。 ## 测试 项目当前无自动化测试套件。验证方式: - `cd desktop && npm run tauri dev` 启动桌面端手动测试 -- `cd backend && go run cmd/server/main.go` 启动后端,用 curl 测试 API -- Docker: `docker compose up -d` 后访问 `http://localhost:8090` +- `cd backend && go run cmd/server/main.go` 启动后端,用 `curl http://localhost:8080/api/v1/health` 测试健康检查 +- Docker: `docker compose up -d` 后访问 `http://localhost:8090`;容器健康检查会通过 Nginx 同时探测 `/` 和 `/api/v1/health` ## CI/CD - **release.yml**: 推送 `v*` tag 触发,构建 macOS ARM/Universal + Windows x64,生成 latest.json + .sig 签名 -- **pr-check.yml**: PR 触发,运行后端 `go vet` + 前端 `npm run build` 检查 +- **pr-check.yml**: PR 触发,运行后端 `go vet` + 前端 `npm run build` 检查;Smoke Test 先 `go build` 后端二进制,再以 `DISABLE_STDIN_MONITOR=1` 启动并读取 `SERVER_PORT` 调用 `/api/v1/health`,避免首次 `go run` 下载/编译依赖导致 30 秒健康检查窗口超时,也兼容端口自动递增 +- CI 的 Go 版本统一通过 `backend/go.mod` 的 `go` 指令读取,不在 workflow 中写死版本号 +- Release 构建在 `desktop/package-lock.json` 存在时使用 `npm ci`,保证依赖安装严格跟随 lockfile - GitHub Secrets 需配置:`TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` diff --git a/Dockerfile b/Dockerfile index 88b295f..f09bbb7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -100,9 +100,9 @@ VOLUME ["/app/storage"] # 暴露端口 EXPOSE 80 -# 健康检查 +# 健康检查:同时覆盖 Nginx/前端入口和经 Nginx 代理的后端 API HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ - CMD wget -q --spider http://localhost:8080/api/v1/health || wget -q --spider http://localhost:80/api/v1/health || exit 1 + CMD wget -q -T 3 -t 1 --spider http://localhost/ && wget -q -T 3 -t 1 --spider http://localhost/api/v1/health || exit 1 # 启动脚本:同时运行 Nginx 和后端服务 CMD sh -c "mkdir -p /app/storage/local && nginx && cd /app && ./server" diff --git a/README.md b/README.md index 89305e6..2e165da 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ - **💾 智能历史管理**:内置本地数据库与持久化缓存,支持任务状态自动恢复与大批量历史记录秒开。 - **📸 精准图生图**:支持多参考图输入,提供细腻的风格与构图控制。 - **📦 自动化交付**:集成 GitHub Actions,实现 macOS (Intel/M1) 与 Windows 平台的自动化打包发布。 -- **🧩 模板市场**:启动时优先拉取远程模板 JSON,失败自动回退内置模板,并支持模板来源与技巧提示。 +- **🧩 模板市场**:启动时优先拉取远程模板 JSON,失败自动回退内置模板,并支持模板来源与技巧提示;935+ 模板使用虚拟化渲染,筛选后也只挂载可见卡片。 --- @@ -56,7 +56,7 @@ - **实时进度追踪**:提供清晰的进度条与状态显示,生成过程中的每一张图片都有对应的占位卡片,完成后自动刷新。 ### 2. 强大的图生图 (Image-to-Image) -- **多图参考支持**:最多可同时添加 10 张参考图,帮助 AI 更好地理解您想要的构图或风格。 +- **多图参考支持**:最多可同时添加 10 张参考图,单张参考图不超过 20MB,单次请求参考图总大小不超过 80MB,帮助 AI 更好地理解您想要的构图或风格。 - **逆向提示词提取**:点击参考图上的「反推提示词」按钮,AI 自动分析图片内容并生成详细的提示词,支持中/英/日/韩等 20+ 种语言输出。 - **灵活的添加方式**: - **点击/拖拽**:直接从本地文件夹选取或拖入图片。 @@ -79,11 +79,13 @@ ### 5. 任务与历史记录 - **全自动持久化**:所有生成记录实时保存至本地数据库,重启软件也不丢失。 +- **轻量历史缓存**:桌面端 localStorage 只缓存最近一小段历史列表快照与分页元信息,不保存 `asset://`/HTTP 派生展示 URL;完整历史仍按需从本地数据库加载,继续支持“加载更多”。 - **智能搜索**:支持通过关键字快速找回历史任务。 - **稳定连接保障**:自动切换 WebSocket 与 HTTP 轮询模式,确保在复杂网络环境下生成任务不中断。 ### 6. 模板市场 (Template Market) - **海量资源**:目前已收录 **935+** 优质模板,涵盖多种风格与行业。 +- **虚拟化渲染**:桌面端模板市场保持单一纵向滚动体验,搜索/筛选区会随模板列表一起滚走;模板网格只渲染当前可见区域的卡片,保留 2/3/4 列响应式布局,搜索和多维筛选大量模板时仍保持流畅。 - **下拉打开**:顶部“拉绳”交互,向下拉出整版模板市场。 - **多维筛选**:支持搜索、渠道/物料/行业/画幅比例筛选。 - **PPT 类目**:标记为 `PPT` 的 16:9 模板会集中展示,便于制作演示稿素材。 @@ -240,7 +242,7 @@ graph TD ├── desktop/ # Tauri 桌面端项目 (React + Rust) │ ├── src/ # 前端组件与业务逻辑 │ └── src-tauri/ # Rust 容器配置与系统权限定义 -├── frontend/ # 独立 Web 版前端 (保留参考) +├── frontend/ # 独立 Web 版前端(Docker 构建入口,版本跟随 desktop/package.json) └── assets/ # 项目展示资源 (预览图等) ``` @@ -268,8 +270,16 @@ sudo xattr -r -d com.apple.quarantine "/Applications/大香蕉 AI.app" cd backend # 复制并配置 config.yaml 填入您的 API Key go run cmd/server/main.go +# 标准健康检查接口 +curl http://localhost:8080/api/v1/health ``` +后端 Provider verbose 诊断日志与错误返回默认只记录响应状态、耗时、请求 ID、响应长度和脱敏后的有界预览;Gemini / OpenAI / OpenAI Image 返回的完整响应体、未脱敏错误体与完整 base64 图片数据不会写入日志,便于排查问题的同时避免日志膨胀和敏感内容泄露。 + +后端 HTTP Server 默认启用连接级保护:请求头读取超时 5 秒、完整请求读取超时 30 秒、空闲连接超时 120 秒。任务状态流接口(`/api/v1/tasks/:task_id/stream`)使用 SSE 长连接持续推送生成状态,因此全局写超时保持关闭,避免长时间生成任务的状态连接被服务器主动截断。 + +Worker 会按 provider 配置为每个生成任务创建任务级 context,并在 worker goroutine 内直接执行 provider `Generate`。Provider 必须把这个 context 继续传递给 HTTP 请求或其他长耗时操作;如 OpenAI Image edits 这类 multipart 流式请求内部使用 `io.Pipe`,也会在 context 取消时主动关闭管道,避免后台写入 goroutine 滞留。如果调用结束时 context 已超时,任务会记录为 `生成超时(...)`,如果 provider 内部 panic,会被转换为任务失败而不会导致 worker 崩溃。 + 或者使用 Makefile 快捷命令: ```bash make build # 编译后端 @@ -283,6 +293,36 @@ npm install npm run tauri dev ``` +本地打包桌面安装包时请使用会先生成 Go sidecar 的脚本: +```bash +cd desktop +npm run tauri:build:local +``` + +模板市场使用单一纵向滚动容器:搜索、筛选和模板卡片会像普通页面一样一起滚动,用户下滑后筛选区自然离开视口;模板卡片区域仍按当前视口只渲染可见卡片,避免 935+ 模板一次性挂载。 + +模板市场虚拟网格的列数和断点统一维护在 `TemplateMarketDrawer.tsx` 顶部的 `TEMPLATE_GRID_COLUMNS` 与 `TEMPLATE_GRID_BREAKPOINTS` 常量中;调整 2/3/4 列布局时请改这些常量,不要在计算函数里散落魔法数字。 + +桌面端图片 URL 生成位于批量渲染热路径,默认不会为每张图片输出 `getImageUrl` 转换/回退日志,避免历史记录或模板大量渲染时刷屏。需要排查 asset/http URL 生成问题时,可在开发者工具中执行 `localStorage.setItem('diagnostic.verbose', '1')` 后刷新应用启用诊断日志;关闭时执行 `localStorage.setItem('diagnostic.verbose', '0')` 或清理该键。 + +桌面端组件读取 Zustand 状态时应使用精确 selector,避免 `useConfigStore()` / `useGenerateStore()` / `useToastStore()` 这类整仓订阅;同一组件需要多个字段或 action 时,请使用 `useShallow` 包裹对象 selector,以减少无关状态变化带来的重渲染,并保持现有 store 持久化结构不变。 + +桌面端历史记录由本地数据库保存完整数据,`history-cache` localStorage 仅作为启动期轻量快照:最多保留最近 20 条列表项、`total/page/hasMore/lastLoadedAt` 等分页元信息,并在写入和旧缓存迁移时剥离图片 `url` / `thumbnailUrl` 派生字段。修改 `desktop/src/store/historyStore.ts` 时请保持缓存有界,避免把完整历史、base64、预览 URL 或其他可重新计算的展示字段写入 localStorage。 + +桌面端 Provider 配置 API 的权威入口是 `desktop/src/services/providerApi.ts`:`ProviderConfig` 类型、`getProviders` 和 `/providers/config` 更新逻辑都从这里维护。`desktop/src/services/configApi.ts` 仅保留兼容旧调用的重导出或旧 GET 包装,新增调用请直接从 `providerApi.ts` 导入,避免两份 provider 配置类型或更新方法再次分叉。 + +桌面端当前不使用 React Query,也不挂载 `QueryClientProvider`。数据请求统一从 `desktop/src/services/` 的显式 API helper 发起,结果根据业务需要进入 Zustand store 或组件本地 state;如果后续要迁移到 React Query,应先选择单个 service 调用作为起点,并同时定义 query key、缓存失效策略以及与 Zustand 的职责分工。 + +桌面端 i18n 默认只随启动加载 `zh-CN` 语言包,`en-US`、`ja-JP`、`ko-KR` 会在用户首次切换到对应语言前按需动态加载。语言资源加载完成后才调用 i18next 切换,因此加载期间会继续显示当前语言,减少切换闪烁并避免启动时一次性打包解析所有 locale JSON。 + +桌面端模板市场已按职责拆分:`TemplateMarketDrawer.tsx` 继续负责抽屉开合、模板数据加载、过滤状态、虚拟网格、预览与应用模板;搜索框、已选筛选 chip、分类筛选按钮、结果数量和刷新按钮由 `desktop/src/components/TemplateMarket/TemplateMarketFilters.tsx` 维护。调整模板市场时请保持这个边界,避免把筛选 UI、虚拟化网格和预览/应用逻辑重新混在同一个组件里。 + +桌面端参考图上传已将粘贴逻辑拆到 `desktop/src/components/ConfigPanel/useReferenceImagePaste.ts`:该 hook 负责剪贴板图片提取、Tauri 原生剪贴板兜底和全局 paste 捕获;`ReferenceImageUpload.tsx` 继续负责区域 UI、点击/拖拽添加、压缩、持久化、预览与排序。调整参考图粘贴行为时请优先修改该 hook,并保持添加、删除、拖拽、粘贴和现有样式不变。 + +桌面端参考图拖拽的临时二进制缓存使用固定的 `window.__BANANA_DRAG_IMAGE_DATA__` 字段,并通过运行时类型守卫读取,避免动态全局索引带来的静态扫描误报。 + +桌面端设置弹窗按 section/field group 渐进拆分:`SettingsModal.tsx` 仍负责标签页、provider 切换、保存、测试连接和跨 section 状态;`desktop/src/components/Settings/ProviderConnectionFields.tsx` 负责 provider 连接字段(Base URL、API Key、显隐按钮、云雾推荐入口和警告/提示插槽)。后续拆分设置页时请一次只抽一个清晰职责,保持现有行为和 Tailwind 样式不变。 + ### 4. Web 前端开发 ```bash cd frontend @@ -290,6 +330,8 @@ npm install npm run dev ``` +独立 Web 前端继续保留在 `frontend/`,Dockerfile 也从该目录执行 `npm ci` 与 `npm run build`。因此 Web 包的 `frontend/package.json` 与 `frontend/package-lock.json` 根版本必须跟随当前桌面端 `desktop/package.json`,保持 Docker Web 镜像和桌面发布使用同一应用版本号;这只是版本元数据同步,不代表 Docker 构建迁移到 `desktop/`。 + ### 5. 自动化构建 (GitHub Actions) 只需推送带有版本号的标签(如 `v2.8.0`),即可触发自动化构建: ```bash @@ -298,6 +340,9 @@ git push origin v2.8.0 ``` > **注意**:v2.8.0 支持通过推送 Tag 自动生成 Release 并上传多平台二进制文件。 +> CI 中的 Go 版本统一跟随 `backend/go.mod` 的 `go` 指令,更新 Go 版本时请先修改该文件,不要在 GitHub Actions workflow 中写死版本号。 +> PR smoke test 会先 `go build` 出后端二进制再启动健康检查,避免首次依赖下载/编译时间被算进 30 秒启动窗口;启动时会设置 `DISABLE_STDIN_MONITOR=1` 并读取后端输出的 `SERVER_PORT` 做健康检查,兼容端口自动递增。 +> Release 构建会在 `desktop/` 目录使用 `npm ci` 安装前端依赖,确保依赖版本严格跟随 `desktop/package-lock.json`。 ### 6. 自动更新 (Updater) 项目已集成 Tauri 官方 Updater 插件,发布新版本后用户启动应用会收到更新提示,可一键下载安装。 @@ -348,7 +393,11 @@ cat ~/.tauri/banana-updater.key | `对话模型` | 用于提示词优化功能。 | | `Storage Dir` | 应用默认将图片保存在系统的 `AppData` (Win) 或 `Application Support` (Mac) 目录下。 | | `Templates Remote URL` | 远程模板 JSON 地址(默认 GitHub Raw),启动时会拉取并缓存。 | -| `asset://` | 自定义资源协议,用于安全、快速地访问本地生成的图片。 | +| `asset://` | 自定义资源协议,仅允许读取应用数据、应用配置、应用缓存和临时目录内的本地图片,不再开放整个用户主目录。 | + +后端标准健康检查接口为 `GET /api/v1/health`,GitHub Actions 与 Docker/本地 smoke test 均应使用该路径。 + +桌面端 Tauri 安全边界默认开启最小 CSP:页面脚本仅来自应用自身,启动页内联样式因防白屏保留,图片允许 `asset:`、`blob:`、`data:` 以及模板/代理所需的 `http(s)` 来源,API/SSE 连接限制在 localhost 动态端口并兼容必要的 `http(s)` 模板图片读取。开发时如果新增图片来源、Web Worker 或网络请求,请同步收紧更新 `desktop/src-tauri/tauri.conf.json`,不要把 `csp` 设回 `null` 或把 `assetProtocol.scope` 放宽到 `$HOME/**`。 > **提示**:OpenAI 类型接口通常要求生图模型(model_id)必填;Gemini 类型需使用 `/v1beta` 路径。`OpenAI Image` 类型默认使用 `gpt-image-2` 模型,不支持 `quality` 参数。 @@ -358,6 +407,10 @@ cat ~/.tauri/banana-updater.key 桌面版不适合 Docker 运行,以下仅用于 **后端 + Web 前端** 的部署。 +Docker Web 镜像构建的是 `frontend/` 独立前端,其 package 版本跟随 `desktop/package.json` 的当前应用版本,避免发布说明、镜像元数据与桌面端版本不一致。 + +Docker 运行时由 Nginx 代理 `/api/` 到后端,并使用标准 `$connection_upgrade` 映射处理 `Upgrade` / `Connection` 头:普通 API 请求不会被强制标记为 upgrade,WebSocket 或 SSE 等长连接场景仍保留 HTTP/1.1、Upgrade 头和 300 秒代理读写超时,避免生成任务状态连接被中途截断。主容器健康检查也通过 Nginx 同时探测 `/` 前端入口和 `/api/v1/health` 后端 API,避免只检查直连后端而漏掉 Nginx 或静态前端不可用的问题。 + 项目提供完整的 Docker 部署方案,支持一键启动、国内镜像源加速、数据持久化等功能。 ### 快速开始 @@ -394,7 +447,8 @@ GO_PROXY=https://goproxy.cn,direct - 🐳 **多阶段构建**:前端(Node.js)+ 后端(Go)+ 运行时(Alpine + Nginx) - 🚀 **环境自动检测**:后端自动识别 Docker 环境,监听 `0.0.0.0`(Tauri 监听 `127.0.0.1`) - 💾 **数据持久化**:图片存储和数据库自动挂载到 `./data/storage` -- 🔄 **健康检查**:内置健康检查接口,自动重启异常容器 +- 🔌 **长连接代理**:Nginx API 代理使用 upgrade-safe 头部映射,兼容普通 HTTP、WebSocket 与 SSE 状态流 +- 🔄 **健康检查**:Dockerfile 与 Compose 均通过 Nginx 同时检查 `/` 前端入口和 `GET /api/v1/health` 后端 API,自动重启异常容器 - 🇨🇳 **镜像源支持**:通过 Build Args 配置国内镜像源,保持 Dockerfile 通用性 --- diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index b3e1250..bd0f267 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -163,6 +163,18 @@ func getDefaultHost(configuredHost string) string { return "127.0.0.1" } +func newHTTPServer(addr string, handler http.Handler) *http.Server { + return &http.Server{ + Addr: addr, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 30 * time.Second, + // SSE 任务流需要在长时间生成期间持续保活,不能用全局写超时截断连接。 + WriteTimeout: 0, + IdleTimeout: 120 * time.Second, + } +} + func main() { workDir := getWorkDir() log.Printf("Working directory: %s", workDir) @@ -352,10 +364,7 @@ func main() { log.Println("标准输入监听已禁用(Docker/生产模式)") } - srv := &http.Server{ - Addr: net.JoinHostPort(host, strconv.Itoa(port)), - Handler: r, - } + srv := newHTTPServer(net.JoinHostPort(host, strconv.Itoa(port)), r) go func() { if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { diff --git a/backend/cmd/server/main_test.go b/backend/cmd/server/main_test.go new file mode 100644 index 0000000..d5a9b4b --- /dev/null +++ b/backend/cmd/server/main_test.go @@ -0,0 +1,24 @@ +package main + +import ( + "net/http" + "testing" + "time" +) + +func TestNewHTTPServerConfiguresConnectionTimeouts(t *testing.T) { + server := newHTTPServer("127.0.0.1:0", http.NewServeMux()) + + if server.ReadHeaderTimeout != 5*time.Second { + t.Fatalf("ReadHeaderTimeout = %s, want 5s", server.ReadHeaderTimeout) + } + if server.ReadTimeout != 30*time.Second { + t.Fatalf("ReadTimeout = %s, want 30s", server.ReadTimeout) + } + if server.WriteTimeout != 0 { + t.Fatalf("WriteTimeout = %s, want 0 to preserve SSE streams", server.WriteTimeout) + } + if server.IdleTimeout != 120*time.Second { + t.Fatalf("IdleTimeout = %s, want 120s", server.IdleTimeout) + } +} diff --git a/backend/internal/api/handlers.go b/backend/internal/api/handlers.go index bd7036c..bc3c4f7 100644 --- a/backend/internal/api/handlers.go +++ b/backend/internal/api/handlers.go @@ -207,13 +207,25 @@ func validateRefPathForTauri(raw string) (string, error) { if normalizedStorage := normalizeStoragePath(trimmed); normalizedStorage != "" { if configDir, err := os.UserConfigDir(); err == nil && strings.TrimSpace(configDir) != "" { candidate := filepath.Join(configDir, "com.dztool.banana", strings.TrimPrefix(normalizedStorage, "/")) - if _, err := os.Stat(candidate); err == nil { + if info, err := os.Stat(candidate); err == nil { + if err := validateReferenceImageRegularFile(filepath.Base(candidate), info.Mode().IsRegular()); err != nil { + return "", err + } + if err := validateReferenceImageSize(filepath.Base(candidate), info.Size()); err != nil { + return "", err + } return filepath.Clean(candidate), nil } } if cacheDir, err := os.UserCacheDir(); err == nil && strings.TrimSpace(cacheDir) != "" { candidate := filepath.Join(cacheDir, "com.dztool.banana", strings.TrimPrefix(normalizedStorage, "/")) - if _, err := os.Stat(candidate); err == nil { + if info, err := os.Stat(candidate); err == nil { + if err := validateReferenceImageRegularFile(filepath.Base(candidate), info.Mode().IsRegular()); err != nil { + return "", err + } + if err := validateReferenceImageSize(filepath.Base(candidate), info.Size()); err != nil { + return "", err + } return filepath.Clean(candidate), nil } } @@ -233,6 +245,16 @@ func validateRefPathForTauri(raw string) (string, error) { } for _, root := range allowedRefPathRoots() { if pathWithinRoot(real, root) { + info, err := os.Stat(real) + if err != nil { + return "", fmt.Errorf("读取本地参考图失败: %w", err) + } + if err := validateReferenceImageRegularFile(filepath.Base(real), info.Mode().IsRegular()); err != nil { + return "", err + } + if err := validateReferenceImageSize(filepath.Base(real), info.Size()); err != nil { + return "", err + } return real, nil } } @@ -541,7 +563,12 @@ func GenerateWithImagesHandler(c *gin.Context) { var refImageBytes []interface{} for _, file := range req.RefImages { if len(file.Content) > 0 { - refImageBytes = append(refImageBytes, file.Content) + nextRefImageBytes, err := appendReferenceImageBytes(refImageBytes, file.Name, file.Content) + if err != nil { + Error(c, http.StatusBadRequest, 400, err.Error()) + return + } + refImageBytes = nextRefImageBytes } } @@ -556,17 +583,64 @@ func GenerateWithImagesHandler(c *gin.Context) { validatedPath, validateErr := validateRefPathForTauri(path) if validateErr != nil { log.Printf("[API] 非法本地参考图路径: %s, err: %v\n", path, validateErr) - Error(c, http.StatusBadRequest, 400, "参考图路径不在允许目录内") + if strings.Contains(validateErr.Error(), "参考图") { + Error(c, http.StatusBadRequest, 400, validateErr.Error()) + } else { + Error(c, http.StatusBadRequest, 400, "参考图路径不在允许目录内") + } return } - targetPath = validatedPath - content, err := os.ReadFile(targetPath) + targetPath = filepath.Clean(validatedPath) + // nosemgrep -- validateRefPathForTauri resolves symlinks, restricts paths to app config/cache/temp roots, and checks regular file + size before open. + file, err := os.Open(targetPath) // #nosec G304 if err != nil { - log.Printf("[API] 读取本地参考图失败: %s, err: %v\n", targetPath, err) + log.Printf("[API] 打开本地参考图失败: %s, err: %v\n", targetPath, err) Error(c, http.StatusBadRequest, 400, "读取本地参考图失败") return } - refImageBytes = append(refImageBytes, content) + info, err := file.Stat() + if err != nil { + file.Close() + log.Printf("[API] 检查本地参考图失败: %s, err: %v\n", targetPath, err) + Error(c, http.StatusBadRequest, 400, "读取本地参考图失败") + return + } + if err := validateReferenceImageRegularFile(filepath.Base(targetPath), info.Mode().IsRegular()); err != nil { + file.Close() + Error(c, http.StatusBadRequest, 400, err.Error()) + return + } + if err := validateReferenceImageCount(len(refImageBytes) + 1); err != nil { + file.Close() + Error(c, http.StatusBadRequest, 400, err.Error()) + return + } + if err := validateReferenceImageSize(filepath.Base(targetPath), info.Size()); err != nil { + file.Close() + Error(c, http.StatusBadRequest, 400, err.Error()) + return + } + if err := validateReferenceImagesTotalBytes(totalReferenceImageBytes(refImageBytes) + info.Size()); err != nil { + file.Close() + Error(c, http.StatusBadRequest, 400, err.Error()) + return + } + content, err := readAndCloseReferenceImage(file, filepath.Base(targetPath)) + if err != nil { + log.Printf("[API] 读取本地参考图失败: %s, err: %v\n", targetPath, err) + if strings.Contains(err.Error(), "参考图") { + Error(c, http.StatusBadRequest, 400, err.Error()) + } else { + Error(c, http.StatusBadRequest, 400, "读取本地参考图失败") + } + return + } + nextRefImageBytes, err := appendReferenceImageBytes(refImageBytes, filepath.Base(targetPath), content) + if err != nil { + Error(c, http.StatusBadRequest, 400, err.Error()) + return + } + refImageBytes = nextRefImageBytes } } diff --git a/backend/internal/api/multipart_helper.go b/backend/internal/api/multipart_helper.go index 6645f3b..ba3be13 100644 --- a/backend/internal/api/multipart_helper.go +++ b/backend/internal/api/multipart_helper.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "log" + "mime/multipart" "strconv" "strings" @@ -35,6 +36,133 @@ type MultipartRequest struct { RefPaths []string } +const ( + maxReferenceImageCount = 10 + maxReferenceImageSizeBytes = 20 * 1024 * 1024 + maxReferenceImagesTotalByte = 80 * 1024 * 1024 +) + +func referenceImageLimitMB(limit int64) int64 { + return limit / 1024 / 1024 +} + +func referenceImagesTotalBytes(files []MultipartFile) int64 { + var total int64 + for _, file := range files { + total += int64(len(file.Content)) + } + return total +} + +func totalReferenceImageBytes(images []interface{}) int64 { + var total int64 + for _, image := range images { + if content, ok := image.([]byte); ok { + total += int64(len(content)) + } + } + return total +} + +func validateReferenceImageCount(nextCount int) error { + if nextCount > maxReferenceImageCount { + return fmt.Errorf("参考图数量超过限制: 当前 %d 张,最多 %d 张", nextCount, maxReferenceImageCount) + } + return nil +} + +func validateReferenceImageSize(name string, size int64) error { + if size > maxReferenceImageSizeBytes { + return fmt.Errorf("参考图 %s 大小超过限制: 当前 %.2fMB,单张最多 %dMB", name, float64(size)/1024/1024, referenceImageLimitMB(maxReferenceImageSizeBytes)) + } + return nil +} + +func validateReferenceImageRegularFile(name string, isRegular bool) error { + if !isRegular { + return fmt.Errorf("参考图 %s 不是普通文件", name) + } + return nil +} + +func validateReferenceImagesTotalBytes(nextTotal int64) error { + if nextTotal > maxReferenceImagesTotalByte { + return fmt.Errorf("参考图总大小超过限制: 当前 %.2fMB,总计最多 %dMB", float64(nextTotal)/1024/1024, referenceImageLimitMB(maxReferenceImagesTotalByte)) + } + return nil +} + +func validateReferenceImageBytesAppend(currentImages []interface{}, name string, content []byte) error { + if err := validateReferenceImageCount(len(currentImages) + 1); err != nil { + return err + } + if err := validateReferenceImageSize(name, int64(len(content))); err != nil { + return err + } + return validateReferenceImagesTotalBytes(totalReferenceImageBytes(currentImages) + int64(len(content))) +} + +func appendReferenceImageBytes(currentImages []interface{}, name string, content []byte) ([]interface{}, error) { + if err := validateReferenceImageBytesAppend(currentImages, name, content); err != nil { + return currentImages, err + } + return append(currentImages, content), nil +} + +func readReferenceImageWithLimit(reader io.Reader, name string) ([]byte, error) { + content, err := io.ReadAll(io.LimitReader(reader, maxReferenceImageSizeBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取文件失败: %w", err) + } + if err := validateReferenceImageSize(name, int64(len(content))); err != nil { + return nil, err + } + return content, nil +} + +func readAndCloseReferenceImage(file io.ReadCloser, name string) ([]byte, error) { + content, readErr := readReferenceImageWithLimit(file, name) + closeErr := file.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, fmt.Errorf("关闭参考图失败: %w", closeErr) + } + return content, nil +} + +func appendMultipartReferenceImage(req *MultipartRequest, name string, content []byte) error { + if err := validateReferenceImageCount(len(req.RefImages) + 1); err != nil { + return err + } + if err := validateReferenceImageSize(name, int64(len(content))); err != nil { + return err + } + nextTotal := referenceImagesTotalBytes(req.RefImages) + int64(len(content)) + if err := validateReferenceImagesTotalBytes(nextTotal); err != nil { + return err + } + req.RefImages = append(req.RefImages, MultipartFile{ + Name: name, + Content: content, + }) + return nil +} + +func validateFileHeaderBeforeRead(fileHeader *multipart.FileHeader, currentCount int, currentTotal int64) error { + if err := validateReferenceImageCount(currentCount + 1); err != nil { + return err + } + if fileHeader == nil { + return nil + } + if err := validateReferenceImageSize(fileHeader.Filename, fileHeader.Size); err != nil { + return err + } + return validateReferenceImagesTotalBytes(currentTotal + fileHeader.Size) +} + // ParseGenerateRequestFromMultipart 使用 formstream 解析图生图请求 func ParseGenerateRequestFromMultipart(c *gin.Context) (*MultipartRequest, error) { req := &MultipartRequest{ @@ -148,19 +276,22 @@ func ParseGenerateRequestFromMultipart(c *gin.Context) (*MultipartRequest, error // 注册文件处理器 (匹配前端的 refImages) p.Parser.Register("refImages", func(reader io.Reader, header formstream.Header) error { - content, err := io.ReadAll(reader) + name := header.FileName() + if err := validateReferenceImageCount(len(req.RefImages) + 1); err != nil { + return err + } + content, err := readReferenceImageWithLimit(reader, name) if err != nil { - return fmt.Errorf("读取文件失败: %w", err) + return err } - req.RefImages = append(req.RefImages, MultipartFile{ - Name: header.FileName(), - Content: content, - }) - return nil + return appendMultipartReferenceImage(req, name, content) }) // 执行解析 if err := p.Parse(); err != nil { + if strings.Contains(err.Error(), "参考图") { + return nil, err + } // 如果 formstream 解析失败,尝试回退到标准库 log.Printf("[回退] formstream 解析失败: %v, 尝试使用标准库\n", err) return parseWithStandardLibrary(c) @@ -199,19 +330,20 @@ func parseWithStandardLibrary(c *gin.Context) (*MultipartRequest, error) { if err == nil && form.File != nil { files := form.File["refImages"] for _, fileHeader := range files { + if err := validateFileHeaderBeforeRead(fileHeader, len(req.RefImages), referenceImagesTotalBytes(req.RefImages)); err != nil { + return nil, err + } file, err := fileHeader.Open() if err != nil { - continue + return nil, fmt.Errorf("打开参考图失败: %w", err) } - content, err := io.ReadAll(file) - file.Close() + content, err := readAndCloseReferenceImage(file, fileHeader.Filename) if err != nil { - continue + return nil, err + } + if err := appendMultipartReferenceImage(req, fileHeader.Filename, content); err != nil { + return nil, err } - req.RefImages = append(req.RefImages, MultipartFile{ - Name: fileHeader.Filename, - Content: content, - }) } } diff --git a/backend/internal/api/multipart_helper_test.go b/backend/internal/api/multipart_helper_test.go index 6edb145..c6a268e 100644 --- a/backend/internal/api/multipart_helper_test.go +++ b/backend/internal/api/multipart_helper_test.go @@ -2,14 +2,40 @@ package api import ( "bytes" + "context" + "errors" + "fmt" + "image-gen-service/internal/provider" + "io" "mime/multipart" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" ) +type testReferenceImageProvider struct{} + +func (testReferenceImageProvider) Name() string { return "test-reference-provider" } + +func (testReferenceImageProvider) Generate(context.Context, map[string]interface{}) (*provider.ProviderResult, error) { + return nil, nil +} + +func (testReferenceImageProvider) ValidateParams(map[string]interface{}) error { return nil } + +type closeErrorReferenceImageReader struct { + *strings.Reader +} + +func (r closeErrorReferenceImageReader) Close() error { + return errors.New("close failed") +} + func TestParseGenerateRequestFromMultipartIncludesQuality(t *testing.T) { gin.SetMode(gin.TestMode) @@ -47,3 +73,367 @@ func TestParseGenerateRequestFromMultipartIncludesQuality(t *testing.T) { t.Fatalf("Quality = %q, want high", req.Quality) } } + +func TestParseGenerateRequestFromMultipartRejectsTooManyReferenceImages(t *testing.T) { + gin.SetMode(gin.TestMode) + + body, contentType := buildMultipartReferenceImagesRequest(t, []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) + c := newMultipartTestContext(body, contentType) + + _, err := ParseGenerateRequestFromMultipart(c) + if err == nil { + t.Fatalf("ParseGenerateRequestFromMultipart error = nil, want reference image count limit error") + } + if !strings.Contains(err.Error(), "参考图数量") || !strings.Contains(err.Error(), "10") { + t.Fatalf("error = %q, want count limit context", err.Error()) + } +} + +func TestParseGenerateRequestFromMultipartRejectsOversizedReferenceImage(t *testing.T) { + gin.SetMode(gin.TestMode) + + body, contentType := buildMultipartReferenceImagesRequest(t, []int{20*1024*1024 + 1}) + c := newMultipartTestContext(body, contentType) + + _, err := ParseGenerateRequestFromMultipart(c) + if err == nil { + t.Fatalf("ParseGenerateRequestFromMultipart error = nil, want single reference image size limit error") + } + if !strings.Contains(err.Error(), "参考图") || !strings.Contains(err.Error(), "20MB") { + t.Fatalf("error = %q, want single image size limit context", err.Error()) + } +} + +func TestParseGenerateRequestFromMultipartRejectsTotalReferenceImageBytes(t *testing.T) { + gin.SetMode(gin.TestMode) + + body, contentType := buildMultipartReferenceImagesRequest(t, []int{ + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + }) + c := newMultipartTestContext(body, contentType) + + _, err := ParseGenerateRequestFromMultipart(c) + if err == nil { + t.Fatalf("ParseGenerateRequestFromMultipart error = nil, want total reference image bytes limit error") + } + if !strings.Contains(err.Error(), "参考图总大小") || !strings.Contains(err.Error(), "80MB") { + t.Fatalf("error = %q, want total size limit context", err.Error()) + } +} + +func TestParseWithStandardLibraryRejectsOversizedReferenceImage(t *testing.T) { + gin.SetMode(gin.TestMode) + + body, contentType := buildMultipartReferenceImagesRequest(t, []int{20*1024*1024 + 1}) + c := newMultipartTestContext(body, contentType) + + _, err := parseWithStandardLibrary(c) + if err == nil { + t.Fatalf("parseWithStandardLibrary error = nil, want single reference image size limit error") + } + if !strings.Contains(err.Error(), "参考图") || !strings.Contains(err.Error(), "20MB") { + t.Fatalf("error = %q, want single image size limit context", err.Error()) + } +} + +func TestParseWithStandardLibraryRejectsTooManyReferenceImages(t *testing.T) { + gin.SetMode(gin.TestMode) + + body, contentType := buildMultipartReferenceImagesRequest(t, []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) + c := newMultipartTestContext(body, contentType) + + _, err := parseWithStandardLibrary(c) + if err == nil { + t.Fatalf("parseWithStandardLibrary error = nil, want reference image count limit error") + } + if !strings.Contains(err.Error(), "参考图数量") || !strings.Contains(err.Error(), "10") { + t.Fatalf("error = %q, want count limit context", err.Error()) + } +} + +func TestParseWithStandardLibraryRejectsTotalReferenceImageBytes(t *testing.T) { + gin.SetMode(gin.TestMode) + + body, contentType := buildMultipartReferenceImagesRequest(t, []int{ + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + }) + c := newMultipartTestContext(body, contentType) + + _, err := parseWithStandardLibrary(c) + if err == nil { + t.Fatalf("parseWithStandardLibrary error = nil, want total reference image bytes limit error") + } + if !strings.Contains(err.Error(), "参考图总大小") || !strings.Contains(err.Error(), "80MB") { + t.Fatalf("error = %q, want total size limit context", err.Error()) + } +} + +func TestReadAndCloseReferenceImageReturnsCloseError(t *testing.T) { + content, err := readAndCloseReferenceImage(closeErrorReferenceImageReader{Reader: strings.NewReader("image-bytes")}, "close-fails.png") + if err == nil { + t.Fatalf("readAndCloseReferenceImage error = nil, want close error") + } + if !strings.Contains(err.Error(), "关闭参考图失败") || !strings.Contains(err.Error(), "close failed") { + t.Fatalf("error = %q, want close failure context", err.Error()) + } + if content != nil { + t.Fatalf("content = %v, want nil when close fails", content) + } +} + +var _ io.ReadCloser = closeErrorReferenceImageReader{} + +func TestValidateRefPathForTauriRejectsOversizedReferenceImage(t *testing.T) { + realTempRoot, err := filepath.EvalSymlinks(os.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks temp root: %v", err) + } + t.Setenv("TMPDIR", realTempRoot+string(os.PathSeparator)) + dir, err := os.MkdirTemp(realTempRoot, "banana-ref-limit-*") + if err != nil { + t.Fatalf("MkdirTemp under real temp root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + path := filepath.Join(dir, "oversized-reference.png") + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create oversized ref image: %v", err) + } + if err := file.Truncate(20*1024*1024 + 1); err != nil { + file.Close() + t.Fatalf("Truncate oversized ref image: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close oversized ref image: %v", err) + } + + _, err = validateRefPathForTauri(path) + if err == nil { + t.Fatalf("validateRefPathForTauri error = nil, want local reference image size limit error") + } + if !strings.Contains(err.Error(), "参考图") || !strings.Contains(err.Error(), "20MB") { + t.Fatalf("error = %q, want local image size limit context", err.Error()) + } +} + +func TestGenerateWithImagesHandlerReturnsLocalReferenceImageSizeError(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TAURI_PLATFORM", "darwin") + provider.Register(testReferenceImageProvider{}) + + dir := newAllowedTempDir(t) + path := filepath.Join(dir, "oversized-reference.png") + file, err := os.Create(path) + if err != nil { + t.Fatalf("Create oversized ref image: %v", err) + } + if err := file.Truncate(20*1024*1024 + 1); err != nil { + file.Close() + t.Fatalf("Truncate oversized ref image: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("Close oversized ref image: %v", err) + } + + body, contentType := buildMultipartReferencePathsRequest(t, []string{path}) + request := httptest.NewRequest(http.MethodPost, "/api/generate-with-images", body) + request.Header.Set("Content-Type", contentType) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = request + + GenerateWithImagesHandler(c) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "参考图") || !strings.Contains(recorder.Body.String(), "20MB") { + t.Fatalf("body = %q, want local reference image size limit context", recorder.Body.String()) + } +} + +func TestValidateRefPathForTauriRejectsNonRegularReferenceImage(t *testing.T) { + dir := newAllowedTempDir(t) + + _, err := validateRefPathForTauri(dir) + if err == nil { + t.Fatalf("validateRefPathForTauri error = nil, want non-regular reference image error") + } + if !strings.Contains(err.Error(), "普通文件") { + t.Fatalf("error = %q, want non-regular file context", err.Error()) + } +} + +func TestGenerateWithImagesHandlerRejectsTooManyLocalReferencePaths(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TAURI_PLATFORM", "darwin") + provider.Register(testReferenceImageProvider{}) + + paths := makeLocalReferenceImages(t, []int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) + recorder := runGenerateWithImagesForRefPaths(t, paths) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "参考图数量") || !strings.Contains(recorder.Body.String(), "10") { + t.Fatalf("body = %q, want local refPaths count limit context", recorder.Body.String()) + } +} + +func TestGenerateWithImagesHandlerRejectsTotalLocalReferencePathBytes(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("TAURI_PLATFORM", "darwin") + provider.Register(testReferenceImageProvider{}) + + paths := makeLocalReferenceImages(t, []int{ + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + 9 * 1024 * 1024, + }) + recorder := runGenerateWithImagesForRefPaths(t, paths) + + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String()) + } + if !strings.Contains(recorder.Body.String(), "参考图总大小") || !strings.Contains(recorder.Body.String(), "80MB") { + t.Fatalf("body = %q, want local refPaths total size limit context", recorder.Body.String()) + } +} + +func buildMultipartReferenceImagesRequest(t *testing.T, fileSizes []int) (*bytes.Buffer, string) { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + fields := map[string]string{ + "provider": "openai-image", + "model_id": "gpt-image-2", + "prompt": "edit prompt", + "aspectRatio": "1:1", + "imageSize": "2K", + "count": "1", + } + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + t.Fatalf("WriteField %s: %v", key, err) + } + } + for i, size := range fileSizes { + part, err := writer.CreateFormFile("refImages", fmt.Sprintf("ref-%02d.png", i+1)) + if err != nil { + t.Fatalf("CreateFormFile %d: %v", i, err) + } + if _, err := part.Write(bytes.Repeat([]byte{'x'}, size)); err != nil { + t.Fatalf("Write ref image %d: %v", i, err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("Close multipart writer: %v", err) + } + return &body, writer.FormDataContentType() +} + +func buildMultipartReferencePathsRequest(t *testing.T, paths []string) (*bytes.Buffer, string) { + t.Helper() + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + fields := map[string]string{ + "provider": "test-reference-provider", + "model_id": "test-model", + "prompt": "edit prompt", + "aspectRatio": "1:1", + "imageSize": "2K", + "count": "1", + } + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + t.Fatalf("WriteField %s: %v", key, err) + } + } + for _, path := range paths { + if err := writer.WriteField("refPaths", path); err != nil { + t.Fatalf("WriteField refPaths: %v", err) + } + } + if err := writer.Close(); err != nil { + t.Fatalf("Close multipart writer: %v", err) + } + return &body, writer.FormDataContentType() +} + +func newAllowedTempDir(t *testing.T) string { + t.Helper() + + realTempRoot, err := filepath.EvalSymlinks(os.TempDir()) + if err != nil { + t.Fatalf("EvalSymlinks temp root: %v", err) + } + t.Setenv("TMPDIR", realTempRoot+string(os.PathSeparator)) + dir, err := os.MkdirTemp(realTempRoot, "banana-ref-limit-*") + if err != nil { + t.Fatalf("MkdirTemp under real temp root: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(dir) }) + return dir +} + +func makeLocalReferenceImages(t *testing.T, fileSizes []int) []string { + t.Helper() + + dir := newAllowedTempDir(t) + paths := make([]string, 0, len(fileSizes)) + for i, size := range fileSizes { + path := filepath.Join(dir, fmt.Sprintf("ref-%02d.png", i+1)) + if err := os.WriteFile(path, bytes.Repeat([]byte{'x'}, size), 0o600); err != nil { + t.Fatalf("Write local ref image %d: %v", i, err) + } + paths = append(paths, path) + } + return paths +} + +func runGenerateWithImagesForRefPaths(t *testing.T, paths []string) *httptest.ResponseRecorder { + t.Helper() + + body, contentType := buildMultipartReferencePathsRequest(t, paths) + request := httptest.NewRequest(http.MethodPost, "/api/generate-with-images", body) + request.Header.Set("Content-Type", contentType) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = request + + GenerateWithImagesHandler(c) + return recorder +} + +func newMultipartTestContext(body *bytes.Buffer, contentType string) *gin.Context { + request := httptest.NewRequest(http.MethodPost, "/api/generate-with-images", body) + request.Header.Set("Content-Type", contentType) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + c.Request = request + return c +} diff --git a/backend/internal/diagnostic/errors_test.go b/backend/internal/diagnostic/errors_test.go index 930b6c8..97f72a6 100644 --- a/backend/internal/diagnostic/errors_test.go +++ b/backend/internal/diagnostic/errors_test.go @@ -1,6 +1,45 @@ package diagnostic -import "testing" +import ( + "strings" + "testing" +) + +func TestResponseBodySummary_BoundsPreviewAndKeepsLength(t *testing.T) { + longBase64 := "iVBORw0KGgo" + strings.Repeat("A", 600) + body := []byte(`{"data":[{"b64_json":"` + longBase64 + `"}],"request_id":"req-123"}`) + + summary := ResponseBodySummary(body, 80) + + if summary.Length != len(body) { + t.Fatalf("Length = %d, want %d", summary.Length, len(body)) + } + if summary.Preview == "" { + t.Fatalf("Preview is empty") + } + if len([]rune(summary.Preview)) > 95 { + t.Fatalf("Preview length = %d, want bounded preview", len([]rune(summary.Preview))) + } + if strings.Contains(summary.Preview, longBase64) { + t.Fatalf("Preview contains full base64 payload") + } + if !strings.Contains(summary.Preview, "...(truncated)") { + t.Fatalf("Preview = %q, want truncation marker", summary.Preview) + } +} + +func TestResponseBodySummary_RedactsSensitiveFields(t *testing.T) { + body := []byte(`{"api_key":"secret-key","authorization":"Bearer secret-token","ok":true}`) + + summary := ResponseBodySummary(body, 200) + + if strings.Contains(summary.Preview, "secret-key") || strings.Contains(summary.Preview, "secret-token") { + t.Fatalf("Preview = %q, want sensitive values redacted", summary.Preview) + } + if !strings.Contains(summary.Preview, "***REDACTED***") { + t.Fatalf("Preview = %q, want redaction marker", summary.Preview) + } +} func TestSummarizeErrorMessage_ExtractsHTTPStatusFromProviderErrors(t *testing.T) { tests := []struct { diff --git a/backend/internal/diagnostic/logging.go b/backend/internal/diagnostic/logging.go index 17f9791..36119d8 100644 --- a/backend/internal/diagnostic/logging.go +++ b/backend/internal/diagnostic/logging.go @@ -12,6 +12,8 @@ import ( var sensitiveValuePatterns = []*regexp.Regexp{ regexp.MustCompile(`(?i)("api[_-]?key"\s*:\s*")([^"]+)(")`), regexp.MustCompile(`(?i)("authorization"\s*:\s*")([^"]+)(")`), + regexp.MustCompile(`(?i)(\bapi[_-]?key\s*=\s*"?)([^"\s,}]+)("?)`), + regexp.MustCompile(`(?i)(\bauthorization\s*=\s*"?)([^"\s,}]+)("?)`), regexp.MustCompile(`(?i)([?&]key=)([^&\s]+)`), regexp.MustCompile(`(?i)(bearer\s+)([A-Za-z0-9._\-]+)`), } @@ -90,6 +92,23 @@ func Preview(text string, maxRunes int) string { return string(runes[:maxRunes]) + "...(truncated)" } +type ResponseSummary struct { + Length int + Preview string +} + +func ResponseBodySummary(body []byte, maxPreviewRunes int) ResponseSummary { + return ResponseSummary{ + Length: len(body), + Preview: Preview(RedactSensitive(string(body)), maxPreviewRunes), + } +} + +func ResponseBodyErrorPreview(body []byte, maxPreviewRunes int) string { + summary := ResponseBodySummary(body, maxPreviewRunes) + return fmt.Sprintf("body_length=%d body_preview=%s", summary.Length, summary.Preview) +} + func RedactSensitive(text string) string { redacted := text for _, pattern := range sensitiveValuePatterns { diff --git a/backend/internal/provider/error_preview_test.go b/backend/internal/provider/error_preview_test.go new file mode 100644 index 0000000..6e37d28 --- /dev/null +++ b/backend/internal/provider/error_preview_test.go @@ -0,0 +1,146 @@ +package provider + +import ( + "encoding/json" + "image-gen-service/internal/model" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOpenAIProviderErrorPreviewRedactsAndBoundsBody(t *testing.T) { + longBase64 := "iVBORw0KGgo" + strings.Repeat("A", 1600) + secret := "secret-openai-key" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"api_key":"` + secret + `","b64_json":"` + longBase64 + `","request_id":"req-openai"}`)) + })) + defer server.Close() + + p, err := NewOpenAIProvider(&model.ProviderConfig{ + ProviderName: "openai", + APIBase: server.URL, + APIKey: "test-key", + TimeoutSeconds: 5, + MaxRetries: 0, + }) + if err != nil { + t.Fatalf("NewOpenAIProvider: %v", err) + } + + _, _, err = p.doChatRequest(t.Context(), map[string]interface{}{ + "model": "gpt-4o", + "messages": []map[string]string{{"role": "user", "content": "test"}}, + }, nil) + if err == nil { + t.Fatalf("doChatRequest error = nil, want HTTP error") + } + assertSafeProviderError(t, err.Error(), secret, longBase64) + if !strings.Contains(err.Error(), "body_length=") { + t.Fatalf("error = %q, want body_length for troubleshooting", err.Error()) + } +} + +func TestOpenAIImageProviderErrorPreviewRedactsAndBoundsBody(t *testing.T) { + longBase64 := "iVBORw0KGgo" + strings.Repeat("B", 1600) + secret := "secret-image-token" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "error": map[string]interface{}{ + "message": `upstream failed api_key="` + secret + `" b64_json="` + longBase64 + `"`, + }, + }) + })) + defer server.Close() + + p, err := NewOpenAIImageProvider(&model.ProviderConfig{ + ProviderName: "openai-image", + APIBase: server.URL, + APIKey: "test-key", + TimeoutSeconds: 5, + MaxRetries: 0, + }) + if err != nil { + t.Fatalf("NewOpenAIImageProvider: %v", err) + } + + _, _, err = p.doImagesGenerationRequest(t.Context(), &openAIImagesGenerationRequest{ + Model: "gpt-image-2", + Prompt: "test", + Size: "auto", + N: 1, + }, nil) + if err == nil { + t.Fatalf("doImagesGenerationRequest error = nil, want HTTP error") + } + assertSafeProviderError(t, err.Error(), secret, longBase64) +} + +func TestGeminiProviderHTTPErrorPreviewRedactsAndBoundsBody(t *testing.T) { + longBase64 := "iVBORw0KGgo" + strings.Repeat("C", 1600) + secret := "secret-gemini-key" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"api_key":"` + secret + `","inlineData":{"data":"` + longBase64 + `"},"request_id":"req-gemini"}`)) + })) + defer server.Close() + + p := &GeminiProvider{config: &model.ProviderConfig{ + ProviderName: "gemini", + APIBase: server.URL, + APIKey: "test-key", + TimeoutSeconds: 5, + MaxRetries: 0, + }} + + _, _, err := p.doGenerateContent(t.Context(), "gemini-test", &geminiGenerateRequest{ + Contents: []geminiContent{{Role: "user", Parts: []geminiPart{{Text: "test"}}}}, + }, nil) + if err == nil { + t.Fatalf("doGenerateContent error = nil, want HTTP error") + } + assertSafeProviderError(t, err.Error(), secret, longBase64) + if !strings.Contains(err.Error(), "body_length=") { + t.Fatalf("error = %q, want body_length for troubleshooting", err.Error()) + } +} + +func TestGeminiProviderJSONParseErrorPreviewRedactsAndBoundsBody(t *testing.T) { + longBase64 := "iVBORw0KGgo" + strings.Repeat("D", 1600) + secret := "secret-json-key" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"api_key":"` + secret + `","inlineData":{"data":"` + longBase64 + `"}`)) + })) + defer server.Close() + + p := &GeminiProvider{config: &model.ProviderConfig{ + ProviderName: "gemini", + APIBase: server.URL, + APIKey: "test-key", + TimeoutSeconds: 5, + MaxRetries: 0, + }} + + _, _, err := p.doGenerateContent(t.Context(), "gemini-test", &geminiGenerateRequest{ + Contents: []geminiContent{{Role: "user", Parts: []geminiPart{{Text: "test"}}}}, + }, nil) + if err == nil { + t.Fatalf("doGenerateContent error = nil, want parse error") + } + assertSafeProviderError(t, err.Error(), secret, longBase64) +} + +func assertSafeProviderError(t *testing.T, message, secret, fullBase64 string) { + t.Helper() + if strings.Contains(message, secret) { + t.Fatalf("error = %q, want secret redacted", message) + } + if strings.Contains(message, fullBase64) { + t.Fatalf("error contains full base64 payload") + } + if len([]rune(message)) > 1500 { + t.Fatalf("error length = %d, want bounded message", len([]rune(message))) + } +} diff --git a/backend/internal/provider/gemini.go b/backend/internal/provider/gemini.go index 3205cb3..122979a 100644 --- a/backend/internal/provider/gemini.go +++ b/backend/internal/provider/gemini.go @@ -367,25 +367,27 @@ func (p *GeminiProvider) doGenerateContent(ctx context.Context, modelID string, requestID, diagnostic.Preview(strings.Join(headerLines(resp.Header), " | "), 1000), ) + bodySummary := diagnostic.ResponseBodySummary(body, 1200) diagnostic.Logf(params, "response_body", - "status=%s elapsed=%s request_id=%s body=%q", + "status=%s elapsed=%s request_id=%s body_length=%d body_preview=%q", resp.Status, elapsed, requestID, - diagnostic.RedactSensitive(string(body)), + bodySummary.Length, + bodySummary.Preview, ) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyPreview := diagnostic.Preview(string(body), 1200) + bodyPreview := diagnostic.ResponseBodyErrorPreview(body, 1200) if requestID == "" { requestID = diagnostic.ExtractRequestID(string(body)) } - return nil, resp.Header.Clone(), fmt.Errorf("Gemini HTTP %d request_id=%s body=%s", resp.StatusCode, requestID, bodyPreview) + return nil, resp.Header.Clone(), fmt.Errorf("Gemini HTTP %d request_id=%s %s", resp.StatusCode, requestID, bodyPreview) } var parsed geminiGenerateResponse if err := json.Unmarshal(body, &parsed); err != nil { - return nil, resp.Header.Clone(), fmt.Errorf("解析 Gemini 响应 JSON 失败: %w body=%s", err, diagnostic.Preview(string(body), 1200)) + return nil, resp.Header.Clone(), fmt.Errorf("解析 Gemini 响应 JSON 失败: %w %s", err, diagnostic.ResponseBodyErrorPreview(body, 1200)) } return &parsed, resp.Header.Clone(), nil } diff --git a/backend/internal/provider/openai.go b/backend/internal/provider/openai.go index dbee212..402e0c2 100644 --- a/backend/internal/provider/openai.go +++ b/backend/internal/provider/openai.go @@ -265,20 +265,22 @@ func (p *OpenAIProvider) doChatRequest(ctx context.Context, body map[string]inte requestID, diagnostic.Preview(strings.Join(headerLines(resp.Header), " | "), 1000), ) + bodySummary := diagnostic.ResponseBodySummary(respBody, 1200) diagnostic.Logf(params, "response_body", - "status=%s elapsed=%s request_id=%s body=%q", + "status=%s elapsed=%s request_id=%s body_length=%d body_preview=%q", resp.Status, elapsed, requestID, - diagnostic.RedactSensitive(string(respBody)), + bodySummary.Length, + bodySummary.Preview, ) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyPreview := diagnostic.Preview(parseOpenAIError(respBody), 1200) + bodyPreview := openAIErrorBodyPreview(respBody, 1200) if requestID == "" { requestID = diagnostic.ExtractRequestID(string(respBody)) } - return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s body=%s", resp.StatusCode, requestID, bodyPreview) + return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s %s", resp.StatusCode, requestID, bodyPreview) } if len(respBody) == 0 { @@ -598,6 +600,15 @@ func parseOpenAIError(resp []byte) string { return string(resp) } +func openAIErrorBodyPreview(resp []byte, maxPreviewRunes int) string { + parsed := strings.TrimSpace(parseOpenAIError(resp)) + if parsed == "" || parsed == strings.TrimSpace(string(resp)) { + return diagnostic.ResponseBodyErrorPreview(resp, maxPreviewRunes) + } + preview := diagnostic.Preview(diagnostic.RedactSensitive(parsed), maxPreviewRunes) + return fmt.Sprintf("body_length=%d body_preview=%s", len(resp), preview) +} + func decodeDataURL(dataURL string) ([]byte, error) { parts := strings.SplitN(dataURL, ",", 2) if len(parts) != 2 { diff --git a/backend/internal/provider/openai_image.go b/backend/internal/provider/openai_image.go index 1510dd7..6bd22eb 100644 --- a/backend/internal/provider/openai_image.go +++ b/backend/internal/provider/openai_image.go @@ -232,20 +232,22 @@ func (p *OpenAIImageProvider) doImagesGenerationRequest(ctx context.Context, bod requestID, diagnostic.Preview(strings.Join(headerLines(resp.Header), " | "), 1000), ) + bodySummary := diagnostic.ResponseBodySummary(respBody, 1200) diagnostic.Logf(params, "response_body", - "status=%s elapsed=%s request_id=%s body=%q", + "status=%s elapsed=%s request_id=%s body_length=%d body_preview=%q", resp.Status, elapsed, requestID, - diagnostic.RedactSensitive(string(respBody)), + bodySummary.Length, + bodySummary.Preview, ) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyPreview := diagnostic.Preview(parseOpenAIError(respBody), 1200) + bodyPreview := openAIErrorBodyPreview(respBody, 1200) if requestID == "" { requestID = diagnostic.ExtractRequestID(string(respBody)) } - return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s body=%s", resp.StatusCode, requestID, bodyPreview) + return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s %s", resp.StatusCode, requestID, bodyPreview) } if len(respBody) == 0 { @@ -272,7 +274,7 @@ func (p *OpenAIImageProvider) doImagesEditRequest(ctx context.Context, body *ope maxRetries := providerMaxRetries(p.config) var elapsed time.Duration resp, _, err := doRequestWithRetry(ctx, params, p.Name(), maxRetries, func(attempt int) (*http.Response, error) { - reader, contentType := openAIImageEditBody(fields, refs) + reader, contentType := openAIImageEditBody(ctx, fields, refs) req, buildErr := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, reader) if buildErr != nil { return nil, fmt.Errorf("构建 OpenAI Images Edit 请求失败: %w", buildErr) @@ -308,20 +310,22 @@ func (p *OpenAIImageProvider) doImagesEditRequest(ctx context.Context, body *ope requestID, diagnostic.Preview(strings.Join(headerLines(resp.Header), " | "), 1000), ) + bodySummary := diagnostic.ResponseBodySummary(respBody, 1200) diagnostic.Logf(params, "response_body", - "status=%s elapsed=%s request_id=%s body=%q", + "status=%s elapsed=%s request_id=%s body_length=%d body_preview=%q", resp.Status, elapsed, requestID, - diagnostic.RedactSensitive(string(respBody)), + bodySummary.Length, + bodySummary.Preview, ) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyPreview := diagnostic.Preview(parseOpenAIError(respBody), 1200) + bodyPreview := openAIErrorBodyPreview(respBody, 1200) if requestID == "" { requestID = diagnostic.ExtractRequestID(string(respBody)) } - return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s body=%s", resp.StatusCode, requestID, bodyPreview) + return nil, resp.Header.Clone(), fmt.Errorf("OpenAI HTTP %d request_id=%s %s", resp.StatusCode, requestID, bodyPreview) } if len(respBody) == 0 { @@ -345,11 +349,16 @@ func openAIImageEditFields(body *openAIImagesGenerationRequest) map[string]strin return fields } -func openAIImageEditBody(fields map[string]string, refs []openAIImageReference) (io.Reader, string) { +func openAIImageEditBody(ctx context.Context, fields map[string]string, refs []openAIImageReference) (io.Reader, string) { reader, writer := io.Pipe() multipartWriter := multipart.NewWriter(writer) contentType := multipartWriter.FormDataContentType() + go func() { + <-ctx.Done() + _ = writer.CloseWithError(ctx.Err()) + }() + go func() { err := writeOpenAIImageEditMultipart(multipartWriter, fields, refs) if closeErr := multipartWriter.Close(); err == nil { diff --git a/backend/internal/provider/openai_image_test.go b/backend/internal/provider/openai_image_test.go index 522bf2b..727e84a 100644 --- a/backend/internal/provider/openai_image_test.go +++ b/backend/internal/provider/openai_image_test.go @@ -2,6 +2,7 @@ package provider import ( "bytes" + "context" "encoding/base64" "encoding/json" "image" @@ -14,6 +15,7 @@ import ( "net/http/httptest" "strings" "testing" + "time" ) const tinyPNGBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=" @@ -351,3 +353,31 @@ func TestOpenAIImageProviderRejectsInvalidReference(t *testing.T) { t.Fatalf("collectOpenAIImageReferences error = %v, want invalid image error", err) } } + +func TestOpenAIImageEditBodyStopsWriterWhenContextIsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + reader, _ := openAIImageEditBody(ctx, map[string]string{"prompt": "edit prompt"}, []openAIImageReference{ + { + Name: "large-reference.png", + Content: bytes.Repeat([]byte("x"), 4<<20), + MIME: "image/png", + }, + }) + + done := make(chan error, 1) + go func() { + _, err := io.Copy(io.Discard, reader) + done <- err + }() + + cancel() + + select { + case err := <-done: + if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) { + t.Fatalf("reader error = %v, want context canceled", err) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("multipart body reader did not stop after context cancellation") + } +} diff --git a/backend/internal/worker/pool.go b/backend/internal/worker/pool.go index f3d814d..2d211bb 100644 --- a/backend/internal/worker/pool.go +++ b/backend/internal/worker/pool.go @@ -183,11 +183,6 @@ func (wp *WorkerPool) processTask(task *Task) { ctx, cancel := context.WithTimeout(wp.ctx, timeout) defer cancel() - type generateResult struct { - result *provider.ProviderResult - err error - } - callStartedAt := time.Now() log.Printf("任务 %s 调用 Provider 开始: provider=%s model=%s timeout=%s", task.TaskModel.TaskID, task.TaskModel.ProviderName, task.TaskModel.ModelID, timeout) diagnostic.Logf(task.Params, "provider_call_start", @@ -213,76 +208,65 @@ func (wp *WorkerPool) processTask(task *Task) { return } - done := make(chan generateResult, 1) - go func() { - defer func() { - if r := recover(); r != nil { - done <- generateResult{ - err: fmt.Errorf("Provider 执行异常崩溃: %v", r), + result, err := callProviderWithRecovery(p, ctx, task.Params) + elapsed := time.Since(callStartedAt) + ctxErr := ctx.Err() + callErr := err + if ctxErr != nil { + callErr = ctxErr + } + if callErr != nil { + log.Printf("任务 %s 调用 Provider 失败: provider=%s model=%s elapsed=%s err=%v", task.TaskModel.TaskID, task.TaskModel.ProviderName, task.TaskModel.ModelID, elapsed, callErr) + summary := diagnostic.SummarizeError(callErr) + diagnostic.Logf(task.Params, "provider_call_error", + "provider=%s model=%s elapsed=%s error_type=%s error_code=%s category=%s retryable=%t request_id=%s user_message=%q raw_error=%q", + task.TaskModel.ProviderName, + task.TaskModel.ModelID, + elapsed, + summary.Type, + summary.Code, + summary.Category, + summary.Retryable, + summary.RequestID, + summary.UserMessage, + callErr.Error(), + ) + } else { + imageCount := 0 + if result != nil { + imageCount = len(result.Images) + } + log.Printf("任务 %s 调用 Provider 成功: provider=%s model=%s elapsed=%s images=%d", task.TaskModel.TaskID, task.TaskModel.ProviderName, task.TaskModel.ModelID, elapsed, imageCount) + diagnostic.Logf(task.Params, "provider_call_success", + "provider=%s model=%s elapsed=%s images=%d metadata=%v", + task.TaskModel.ProviderName, + task.TaskModel.ModelID, + elapsed, + imageCount, + func() map[string]interface{} { + if result == nil || result.Metadata == nil { + return map[string]interface{}{} + } - } - }() - result, err := p.Generate(ctx, task.Params) - elapsed := time.Since(callStartedAt) - if err != nil { - log.Printf("任务 %s 调用 Provider 失败: provider=%s model=%s elapsed=%s err=%v", task.TaskModel.TaskID, task.TaskModel.ProviderName, task.TaskModel.ModelID, elapsed, err) - summary := diagnostic.SummarizeError(err) - diagnostic.Logf(task.Params, "provider_call_error", - "provider=%s model=%s elapsed=%s error_type=%s error_code=%s category=%s retryable=%t request_id=%s user_message=%q raw_error=%q", - task.TaskModel.ProviderName, - task.TaskModel.ModelID, - elapsed, - summary.Type, - summary.Code, - summary.Category, - summary.Retryable, - summary.RequestID, - summary.UserMessage, - err.Error(), - ) + return result.Metadata + }(), + ) + } + if ctxErr != nil { + if errors.Is(ctxErr, context.DeadlineExceeded) { + wp.failTask(task, fmt.Errorf("生成超时(%s)", timeout)) } else { - imageCount := 0 - if result != nil { - imageCount = len(result.Images) - } - log.Printf("任务 %s 调用 Provider 成功: provider=%s model=%s elapsed=%s images=%d", task.TaskModel.TaskID, task.TaskModel.ProviderName, task.TaskModel.ModelID, elapsed, imageCount) - diagnostic.Logf(task.Params, "provider_call_success", - "provider=%s model=%s elapsed=%s images=%d metadata=%v", - task.TaskModel.ProviderName, - task.TaskModel.ModelID, - elapsed, - imageCount, - func() map[string]interface{} { - if result == nil || result.Metadata == nil { - return map[string]interface{}{} - } - return result.Metadata - }(), - ) + wp.failTask(task, ctxErr) } - done <- generateResult{result: result, err: err} - }() - - var result *provider.ProviderResult - select { - case <-ctx.Done(): - err := ctx.Err() + return + } + if err != nil { if errors.Is(err, context.DeadlineExceeded) { wp.failTask(task, fmt.Errorf("生成超时(%s)", timeout)) } else { wp.failTask(task, err) } return - case out := <-done: - if out.err != nil { - if errors.Is(out.err, context.DeadlineExceeded) { - wp.failTask(task, fmt.Errorf("生成超时(%s)", timeout)) - } else { - wp.failTask(task, out.err) - } - return - } - result = out.result } // 记录配置快照 @@ -354,6 +338,15 @@ func (wp *WorkerPool) processTask(task *Task) { } } +func callProviderWithRecovery(p provider.Provider, ctx context.Context, params map[string]interface{}) (result *provider.ProviderResult, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("Provider 执行异常崩溃: %v", r) + } + }() + return p.Generate(ctx, params) +} + func (wp *WorkerPool) optimizePromptForTask(ctx context.Context, task *Task) error { if task == nil || task.TaskModel == nil { return nil diff --git a/backend/internal/worker/pool_test.go b/backend/internal/worker/pool_test.go index a91eed2..7a9a412 100644 --- a/backend/internal/worker/pool_test.go +++ b/backend/internal/worker/pool_test.go @@ -1,15 +1,48 @@ package worker import ( + "context" + "strings" "testing" "time" "image-gen-service/internal/model" + "image-gen-service/internal/provider" "gorm.io/driver/sqlite" "gorm.io/gorm" ) +type blockingProvider struct { + name string + started chan struct{} + release chan struct{} + finished chan struct{} +} + +type panicProvider struct { + name string +} + +func (p *blockingProvider) Name() string { return p.name } + +func (p *blockingProvider) Generate(ctx context.Context, params map[string]interface{}) (*provider.ProviderResult, error) { + close(p.started) + <-p.release + close(p.finished) + return &provider.ProviderResult{}, nil +} + +func (p *blockingProvider) ValidateParams(params map[string]interface{}) error { return nil } + +func (p *panicProvider) Name() string { return p.name } + +func (p *panicProvider) Generate(ctx context.Context, params map[string]interface{}) (*provider.ProviderResult, error) { + panic("provider exploded") +} + +func (p *panicProvider) ValidateParams(params map[string]interface{}) error { return nil } + func TestFetchProviderTimeoutKeepsOpenAIImageConfig(t *testing.T) { originalDB := model.DB t.Cleanup(func() { @@ -42,3 +75,141 @@ func TestFetchProviderTimeoutKeepsOpenAIImageConfig(t *testing.T) { t.Fatalf("openai timeout = %s, want 150s", got) } } + +func TestProcessTaskWaitsForProviderCallBeforeRecordingTimeout(t *testing.T) { + originalDB := model.DB + t.Cleanup(func() { + model.DB = originalDB + }) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open test database: %v", err) + } + if err := db.AutoMigrate(&model.ProviderConfig{}, &model.Task{}); err != nil { + t.Fatalf("migrate test database: %v", err) + } + model.DB = db + + providerName := "timeout-test-provider" + if err := db.Create(&model.ProviderConfig{ProviderName: providerName, TimeoutSeconds: 1}).Error; err != nil { + t.Fatalf("create provider config: %v", err) + } + + fakeProvider := &blockingProvider{ + name: providerName, + started: make(chan struct{}), + release: make(chan struct{}), + finished: make(chan struct{}), + } + provider.Register(fakeProvider) + + taskModel := model.Task{ + TaskID: "timeout-task", + Prompt: "draw a banana", + ProviderName: providerName, + ModelID: "test-model", + Status: "pending", + TotalCount: 1, + } + if err := db.Create(&taskModel).Error; err != nil { + t.Fatalf("create task: %v", err) + } + + poolCtx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + wp := &WorkerPool{ctx: poolCtx, cancel: cancel} + + processDone := make(chan struct{}) + go func() { + wp.processTask(&Task{TaskModel: &taskModel, Params: map[string]interface{}{}}) + close(processDone) + }() + + select { + case <-fakeProvider.started: + case <-time.After(500 * time.Millisecond): + t.Fatal("provider Generate did not start") + } + + select { + case <-processDone: + t.Fatal("processTask returned before provider Generate finished") + case <-time.After(1100 * time.Millisecond): + } + + close(fakeProvider.release) + + select { + case <-processDone: + case <-time.After(500 * time.Millisecond): + t.Fatal("processTask did not finish after provider Generate returned") + } + + select { + case <-fakeProvider.finished: + case <-time.After(100 * time.Millisecond): + t.Fatal("provider Generate did not finish") + } + + var saved model.Task + if err := db.Where("task_id = ?", taskModel.TaskID).First(&saved).Error; err != nil { + t.Fatalf("reload task: %v", err) + } + if saved.Status != "failed" { + t.Fatalf("task status = %q, want failed", saved.Status) + } + if !strings.Contains(saved.ErrorMessage, "生成超时(1s)") { + t.Fatalf("task error = %q, want generation timeout", saved.ErrorMessage) + } +} + +func TestProcessTaskRecordsProviderPanicAsFailure(t *testing.T) { + originalDB := model.DB + t.Cleanup(func() { + model.DB = originalDB + }) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open test database: %v", err) + } + if err := db.AutoMigrate(&model.ProviderConfig{}, &model.Task{}); err != nil { + t.Fatalf("migrate test database: %v", err) + } + model.DB = db + + providerName := "panic-test-provider" + if err := db.Create(&model.ProviderConfig{ProviderName: providerName, TimeoutSeconds: 1}).Error; err != nil { + t.Fatalf("create provider config: %v", err) + } + provider.Register(&panicProvider{name: providerName}) + + taskModel := model.Task{ + TaskID: "panic-task", + Prompt: "draw a banana", + ProviderName: providerName, + ModelID: "test-model", + Status: "pending", + TotalCount: 1, + } + if err := db.Create(&taskModel).Error; err != nil { + t.Fatalf("create task: %v", err) + } + + poolCtx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + wp := &WorkerPool{ctx: poolCtx, cancel: cancel} + wp.processTask(&Task{TaskModel: &taskModel, Params: map[string]interface{}{}}) + + var saved model.Task + if err := db.Where("task_id = ?", taskModel.TaskID).First(&saved).Error; err != nil { + t.Fatalf("reload task: %v", err) + } + if saved.Status != "failed" { + t.Fatalf("task status = %q, want failed", saved.Status) + } + if !strings.Contains(saved.ErrorMessage, "Provider 执行异常崩溃: provider exploded") { + t.Fatalf("task error = %q, want provider panic failure", saved.ErrorMessage) + } +} diff --git a/desktop/package-lock.json b/desktop/package-lock.json index f1ae882..20782d0 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -1,15 +1,14 @@ { "name": "nano-banana-pro-frontend", - "version": "2.8.3", + "version": "2.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nano-banana-pro-frontend", - "version": "2.8.3", + "version": "2.8.4", "license": "MIT", "dependencies": { - "@tanstack/react-query": "^5.59.20", "@tauri-apps/api": "^2.9.1", "@tauri-apps/cli": "^2.9.6", "@tauri-apps/plugin-dialog": "^2.4.2", @@ -1462,32 +1461,6 @@ "win32" ] }, - "node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/react-query": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", - "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.90.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, "node_modules/@tauri-apps/api": { "version": "2.9.1", "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz", diff --git a/desktop/package.json b/desktop/package.json index 35847d0..91c74f0 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,15 +1,16 @@ { "name": "nano-banana-pro-frontend", "private": true, - "version": "2.8.3", + "version": "2.8.4", "license": "MIT", "description": "大香蕉图片生成工具 - 批量图片生成应用前端", "type": "module", "scripts": { "dev": "vite --host", "build": "tsc && vite build", - "build:sidecar": "cd ../backend && go build -o ../desktop/src-tauri/bin/server-aarch64-apple-darwin ./cmd/server/main.go", + "build:sidecar": "mkdir -p src-tauri/bin && cd ../backend && go build -o ../desktop/src-tauri/bin/server-aarch64-apple-darwin ./cmd/server/main.go", "tauri:build:latest": "npm run build:sidecar && tauri build", + "tauri:build:local": "npm run build:sidecar && tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}'", "tauri": "tauri", "build:check": "vite build", "lint": "eslint .", @@ -18,7 +19,6 @@ "type-check": "tsc --noEmit" }, "dependencies": { - "@tanstack/react-query": "^5.59.20", "@tauri-apps/api": "^2.9.1", "@tauri-apps/cli": "^2.9.6", "@tauri-apps/plugin-dialog": "^2.4.2", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 4aa672e..8af9af6 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -744,7 +744,7 @@ dependencies = [ [[package]] name = "desktop" -version = "2.8.3" +version = "2.8.4" dependencies = [ "arboard", "image", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index e9f6c47..ffc8b91 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "desktop" -version = "2.8.3" +version = "2.8.4" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 0243c92..9e6e735 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "大香蕉 AI", - "version": "2.8.3", + "version": "2.8.4", "identifier": "com.dztool.banana", "build": { "beforeDevCommand": "npm run dev", @@ -20,12 +20,26 @@ } ], "security": { - "csp": null, + "csp": { + "default-src": "'self' asset:", + "script-src": "'self'", + "style-src": "'self' 'unsafe-inline'", + "img-src": "'self' asset: http://asset.localhost https://asset.localhost blob: data: http: https: tauri: ipc:", + "connect-src": "'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* http: https:", + "font-src": "'self' data:", + "worker-src": "'self' blob:", + "media-src": "'self' asset: blob: data:", + "object-src": "'none'", + "base-uri": "'self'", + "frame-ancestors": "'none'" + }, "assetProtocol": { "enable": true, "scope": [ "$APPDATA/**", - "$HOME/**" + "$APPCONFIG/**", + "$APPCACHE/**", + "$TEMP/**" ] }, "capabilities": [ diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 3c77db7..a622c3a 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -1,31 +1,31 @@ -import React, { useEffect } from 'react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { useEffect } from 'react'; import MainLayout from './components/Layout/MainLayout'; import { ToastContainer } from './components/common/Toast'; import { UpdaterModal } from './components/common/UpdaterModal'; import { OnboardingTour } from './components/Onboarding/OnboardingTour'; -import i18n from './i18n'; +import i18n, { changeAppLanguage } from './i18n'; import { useGenerationNotifications } from './hooks/useGenerationNotifications'; import { useConfigStore } from './store/configStore'; import { useGenerateStore } from './store/generateStore'; -const queryClient = new QueryClient(); - function App() { const language = useConfigStore((s) => s.language); + const languageResolved = useConfigStore((s) => s.languageResolved); const generateStatus = useGenerateStore((s) => s.status); const isSubmitting = useGenerateStore((s) => s.isSubmitting); useGenerationNotifications(); useEffect(() => { if (!language) return; - if (i18n.language !== language) { - void i18n.changeLanguage(language); + const nextLanguage = language === 'system' ? languageResolved : language; + if (!nextLanguage) return; + if (i18n.language !== nextLanguage) { + void changeAppLanguage(nextLanguage); } - }, [language]); + }, [language, languageResolved]); useEffect(() => { - const isTauri = typeof window !== 'undefined' && Boolean((window as any).__TAURI_INTERNALS__); + const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__); if (!isTauri) return; const active = generateStatus === 'processing' || isSubmitting; @@ -34,18 +34,18 @@ function App() { const { invoke } = await import('@tauri-apps/api/core'); await invoke('set_generation_active', { active }); } catch (error) { - console.warn('[quit-guard] 同步生成状态失败', error); + console.warn('[quit-guard] Failed to sync generation state', error); } })(); }, [generateStatus, isSubmitting]); return ( - + <> - + ); } diff --git a/desktop/src/components/ConfigPanel/BatchSettings.tsx b/desktop/src/components/ConfigPanel/BatchSettings.tsx index f2d6931..86a9d91 100644 --- a/desktop/src/components/ConfigPanel/BatchSettings.tsx +++ b/desktop/src/components/ConfigPanel/BatchSettings.tsx @@ -1,6 +1,7 @@ import { useMemo, useEffect } from 'react'; import { Info, Settings2 } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; import { useConfigStore, getModelAspectRatios, @@ -29,7 +30,23 @@ export function BatchSettings() { imageModel, imageProvider, refFiles - } = useConfigStore(); + } = useConfigStore( + useShallow((s) => ({ + count: s.count, + setCount: s.setCount, + imageSize: s.imageSize, + setImageSize: s.setImageSize, + imageNativeSize: s.imageNativeSize, + setImageNativeSize: s.setImageNativeSize, + imageQuality: s.imageQuality, + setImageQuality: s.setImageQuality, + aspectRatio: s.aspectRatio, + setAspectRatio: s.setAspectRatio, + imageModel: s.imageModel, + imageProvider: s.imageProvider, + refFiles: s.refFiles + })) + ); const supportedRatios = useMemo(() => getModelAspectRatios(imageModel), [imageModel]); const useNativeSize = isUsingNativeImageSize(imageProvider, imageModel); diff --git a/desktop/src/components/ConfigPanel/PromptInput.tsx b/desktop/src/components/ConfigPanel/PromptInput.tsx index f5e6d15..fc8c369 100644 --- a/desktop/src/components/ConfigPanel/PromptInput.tsx +++ b/desktop/src/components/ConfigPanel/PromptInput.tsx @@ -8,10 +8,29 @@ import { useGenerateStore } from '../../store/generateStore'; import { useTranslation } from 'react-i18next'; const BACKEND_ERROR_MATCHES = { - providerMissing: '\u672a\u627e\u5230\u6307\u5b9a\u7684 Provider', - providerKeyMissing: 'Provider API Key \u672a\u914d\u7f6e', - modelMissing: '\u672a\u627e\u5230\u53ef\u7528\u7684\u6a21\u578b', - promptEmpty: 'prompt \u4e0d\u80fd\u4e3a\u7a7a' + providerMissing: String.fromCharCode(26410, 25214, 21040, 25351, 23450, 30340) + ' Provider', + providerKeyMissing: 'Provider API Key ' + String.fromCharCode(26410, 37197, 32622), + modelMissing: String.fromCharCode(26410, 25214, 21040, 21487, 29992, 30340, 27169, 22411), + promptEmpty: 'prompt ' + String.fromCharCode(19981, 33021, 20026, 31354) +}; + +type PromptOptimizeError = Error & { + response?: { + status?: number; + data?: { + message?: unknown; + }; + }; +}; + +const getPromptOptimizeErrorInfo = (error: unknown) => { + const candidate = error instanceof Error ? (error as PromptOptimizeError) : null; + const status = candidate?.response?.status; + const backendMessage = candidate && typeof candidate.response?.data?.message === 'string' + ? candidate.response.data.message + : ''; + const fallbackMessage = error instanceof Error ? error.message : ''; + return { status, rawMessage: backendMessage || fallbackMessage }; }; export function PromptInput() { @@ -20,7 +39,7 @@ export function PromptInput() { const { history, index, record, undo, redo, reset } = usePromptHistoryStore(); const status = useGenerateStore((s) => s.status); const isSubmitting = useGenerateStore((s) => s.isSubmitting); - const isGenerating = status === 'processing' || isSubmitting; + const _isGenerating = status === 'processing' || isSubmitting; const [isOptimizing, setIsOptimizing] = useState(false); const [optimizingMode, setOptimizingMode] = useState<'normal' | 'json' | null>(null); const debounceRef = useRef | null>(null); @@ -140,11 +159,8 @@ export function PromptInput() { record(nextPrompt); skipRecordRef.current = true; setPrompt(nextPrompt); - } catch (error: any) { - const status = error?.response?.status; - const backendMessage = typeof error?.response?.data?.message === 'string' ? error.response.data.message : ''; - const fallbackMessage = error instanceof Error ? error.message : ''; - const rawMessage = backendMessage || fallbackMessage; + } catch (error: unknown) { + const { status, rawMessage } = getPromptOptimizeErrorInfo(error); const isAxiosStatusMessage = rawMessage.startsWith('Request failed with status code'); let message = rawMessage || t('prompt.toast.optimizeFailed'); diff --git a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx index 1141b21..8abbcb6 100644 --- a/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx +++ b/desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx @@ -10,6 +10,29 @@ import { calculateMd5, compressImage, fetchFileWithMd5 } from '../../utils/image import { getImageUrl } from '../../services/api'; import { useTranslation } from 'react-i18next'; import { imageToPrompt } from '../../services/promptApi'; +import { useReferenceImagePaste } from './useReferenceImagePaste'; + +type PersistMiddlewareApi = { + hasHydrated?: () => boolean; + onFinishHydration?: (callback: () => void) => (() => void) | undefined; +}; + +type StoreWithPersist = typeof useConfigStore & { + persist?: PersistMiddlewareApi; +}; + +type CachedDragImageData = { + blob?: Blob | null; + blobPromise?: Promise; + createdAt?: number; + path?: string; + url?: string; + name?: string; +}; + +type DragSafeStyle = React.CSSProperties & { + WebkitAppRegion?: string; +}; const REF_IMAGE_DIR = 'ref_images'; const REORDER_DRAG_THRESHOLD = 6; @@ -50,6 +73,16 @@ const hashString = (value: string) => SparkMD5.hash(value); const isUrlLike = (value: string) => /^https?:|^asset:|^blob:|^data:|^tauri:|^ipc:|^file:/i.test(value); +const noDragStyle: DragSafeStyle = { WebkitAppRegion: 'no-drag' }; + +const isCachedDragImageData = (value: unknown): value is CachedDragImageData => { + if (!value || typeof value !== 'object') return false; + const candidate = value as Record; + const hasBlob = candidate.blob instanceof Blob || candidate.blob === null || typeof candidate.blob === 'undefined'; + const hasCreatedAt = typeof candidate.createdAt === 'number' || typeof candidate.createdAt === 'undefined'; + return hasBlob && hasCreatedAt; +}; + const normalizePathFromUri = (raw: string): string => { const trimmed = (raw || '').trim(); if (!trimmed) return ''; @@ -164,12 +197,13 @@ export function ReferenceImageUpload() { // 清理 ObjectURL 防止内存泄漏 useEffect(() => { + const objectUrls = objectUrlsRef.current; return () => { // 组件卸载时清理所有 ObjectURL - objectUrlsRef.current.forEach((url) => { + objectUrls.forEach((url) => { URL.revokeObjectURL(url); }); - objectUrlsRef.current.clear(); + objectUrls.clear(); }; }, []); @@ -372,7 +406,7 @@ export function ReferenceImageUpload() { }, [refFiles, calculateMd5Callback]); useEffect(() => { - const persistApi = (useConfigStore as any).persist; + const persistApi = (useConfigStore as StoreWithPersist).persist; if (!persistApi?.hasHydrated || persistApi.hasHydrated()) { void restorePersistedRefFiles(); return; @@ -558,7 +592,7 @@ export function ReferenceImageUpload() { void syncPersistedRefFiles(); } } - }, [calculateMd5Callback, getAppDataDir, refFiles, setRefImageEntries]); + }, [calculateMd5Callback, getAppDataDir, persistExternalRefImage, refFiles, setRefImageEntries]); useEffect(() => { void syncPersistedRefFiles(); @@ -572,7 +606,7 @@ export function ReferenceImageUpload() { }, [setDropTarget]); // 带并发保护的包装函数(添加超时机制) - const withProcessingLock = useCallback(async (fn: () => Promise, timeoutMs: number = 60000) => { + const withProcessingLock = useCallback(async (fn: () => Promise, timeoutMs: number = 60000): Promise => { if (isProcessingRef.current) { throw new Error(BUSY_ERROR_MESSAGE); } @@ -644,14 +678,11 @@ export function ReferenceImageUpload() { // 智能压缩判断:综合考虑文件大小和图片尺寸 const sizeMB = file.size / 1024 / 1024; let shouldCompress = false; - let compressReason = ''; - // 判断是否需要压缩(仅在开启压缩时) if (enableRefImageCompression) { if (sizeMB > 2) { // 文件超过 2MB,必须压缩 shouldCompress = true; - compressReason = t('refImage.compressReason.fileTooLarge', { size: sizeMB.toFixed(2) }); } else if (sizeMB > 1) { // 文件在 1-2MB 之间,检查图片尺寸 let objectUrl = ''; @@ -668,9 +699,8 @@ export function ReferenceImageUpload() { if (maxDimension > 2048) { // 图片尺寸超过 2048px,建议压缩 shouldCompress = true; - compressReason = t('refImage.compressReason.dimensions', { width: dimensions.width, height: dimensions.height }); } - } catch (error) { + } catch { // 尺寸检查失败,跳过压缩 } finally { // 确保在所有情况下都清理 ObjectURL @@ -699,7 +729,7 @@ export function ReferenceImageUpload() { finalFile = compressedFile; finalMd5 = compressedMd5; (compressedFile as ExtendedFile).__md5 = compressedMd5; - } catch (error) { + } catch { // 压缩失败,使用原始文件 if (md5Set.has(md5)) { continue; @@ -792,7 +822,7 @@ export function ReferenceImageUpload() { toast.error(t('refImage.toast.addFailed', { message })); } } - }, [addRefFiles, createImageFileFromUrl, isExpanded, processFilesWithMd5, refFiles.length, withProcessingLock]); + }, [addRefFiles, createImageFileFromUrl, isExpanded, processFilesWithMd5, refFiles.length, t, withProcessingLock]); useEffect(() => { if (!dropPayload) return; @@ -847,178 +877,20 @@ export function ReferenceImageUpload() { // 重置 input 值,允许重复选择同一张图 if (fileInputRef.current) fileInputRef.current.value = ''; - }, [refFiles.length, addRefFiles, withProcessingLock, processFilesWithMd5]); - - const extractImageFilesFromClipboard = useCallback((clipboardData: DataTransfer | null): File[] => { - if (!clipboardData) return []; - const files: File[] = []; - - // 1) items(最常见:截图/复制图片) - const items = clipboardData.items; - if (items && items.length > 0) { - for (let i = 0; i < items.length; i++) { - const item = items[i]; - if (item.type && item.type.startsWith('image/')) { - const file = item.getAsFile(); - if (file) files.push(file); - } - } - } - - // 2) files(部分平台会把图片放在 files 里) - if (clipboardData.files && clipboardData.files.length > 0) { - Array.from(clipboardData.files).forEach((f) => { - if (f.type && f.type.startsWith('image/')) files.push(f); - }); - } - - return files; - }, []); - - const processPastedFiles = useCallback(async (files: File[]) => { - if (files.length === 0) return; - - // 收起状态也允许粘贴:自动展开,避免“无提示/无响应”的体验 - if (!isExpanded) { - setIsExpanded(true); - } - - await withProcessingLock(async () => { - const remainingSlots = 10 - refFiles.length; - if (remainingSlots <= 0) { - toast.error(t('refImage.toast.full')); - return; - } - - const clipped = files.slice(0, remainingSlots); - if (files.length > remainingSlots) { - toast.error(t('refImage.toast.remainingSlots', { count: remainingSlots })); - } - - const uniqueFiles = await processFilesWithMd5(clipped); - if (uniqueFiles.length > 0) { - addRefFiles(uniqueFiles); - const compressedFiles = uniqueFiles.filter(f => (f as ExtendedFile).__compressed); - if (compressedFiles.length > 0) { - toast.success(t('refImage.toast.addedCompressed', { count: uniqueFiles.length, compressed: compressedFiles.length })); - } else { - toast.success(t('refImage.toast.addedCount', { count: uniqueFiles.length })); - } - } else { - toast.info(t('refImage.toast.exists')); - } - }); - }, [isExpanded, refFiles.length, addRefFiles, withProcessingLock, processFilesWithMd5]); - - const tryPasteFromTauriClipboard = useCallback(async () => { - const remainingSlots = 10 - refFiles.length; - if (remainingSlots <= 0) { - toast.error(t('refImage.toast.full')); - return; - } - - // 收起状态也允许粘贴:自动展开 - if (!isExpanded) { - setIsExpanded(true); - } - - try { - const { invoke } = await import('@tauri-apps/api/core'); - const path = await invoke('read_image_from_clipboard'); - const imagePath = (path || '').trim(); - if (!imagePath) return; // 剪贴板里没有图片:静默忽略 - - const md5Key = buildPathMd5(imagePath); - if (fileMd5SetRef.current.has(md5Key)) { - toast.info(t('refImage.toast.exists')); - return; - } - - const name = imagePath.split(/[/\\]/).pop() || `clipboard-${Date.now()}.png`; - const file = new File([], name, { type: 'image/png' }) as ExtendedFile; - file.__path = imagePath; - file.__md5 = md5Key; - - addRefFiles([file]); - toast.success(t('refImage.toast.addedOne')); - } catch (err) { - // 原生读取失败:静默忽略,避免影响正常文本粘贴体验 - console.warn('[ReferenceImageUpload] read_image_from_clipboard failed:', err); - } - }, [isExpanded, refFiles.length, addRefFiles]); - - // 处理粘贴上传(React 事件) - const handlePaste = useCallback(async (e: React.ClipboardEvent) => { - const files = extractImageFilesFromClipboard(e.clipboardData || null); - if (files.length > 0) { - e.preventDefault(); - e.stopPropagation(); - try { - await processPastedFiles(files); - } catch (error) { - if (error instanceof Error && error.message === BUSY_ERROR_MESSAGE) { - toast.info(t('refImage.toast.busy')); - } else { - console.error('Paste image failed:', error); - const message = error instanceof Error ? error.message : t('refImage.toast.unknown'); - toast.error(t('refImage.toast.pasteFailed', { message })); - } - } - return; - } - - const isTauri = typeof window !== 'undefined' && Boolean((window as any).__TAURI_INTERNALS__); - if (!isTauri) return; - - // 如果用户在粘贴纯文本(且当前在输入框内),不要触发原生读取,避免拖慢输入体验 - const plain = (e.clipboardData?.getData('text/plain') || '').trim(); - const target = e.target as HTMLElement | null; - const isTextInputTarget = Boolean( - target && - ((target as any).isContentEditable || - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA') - ); - if (plain && isTextInputTarget) return; - - // 兜底:Tauri 打包环境下 Web ClipboardData 可能拿不到图片数据,尝试原生读取 - void tryPasteFromTauriClipboard(); - }, [extractImageFilesFromClipboard, processPastedFiles, tryPasteFromTauriClipboard]); - - // 全局 paste 捕获:不要求用户必须聚焦参考图区域 - useEffect(() => { - const onPaste = (e: ClipboardEvent) => { - if (!e.clipboardData) return; - - const files = extractImageFilesFromClipboard(e.clipboardData); - if (files.length > 0) { - e.preventDefault(); - e.stopPropagation(); - void processPastedFiles(files); - return; - } - - const isTauri = typeof window !== 'undefined' && Boolean((window as any).__TAURI_INTERNALS__); - if (!isTauri) return; - - const plain = (e.clipboardData.getData('text/plain') || '').trim(); - const target = e.target as HTMLElement | null; - const isTextInputTarget = Boolean( - target && - ((target as any).isContentEditable || - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA') - ); - if (plain && isTextInputTarget) return; - - void tryPasteFromTauriClipboard(); - }; - - window.addEventListener('paste', onPaste, true); - return () => { - window.removeEventListener('paste', onPaste, true); - }; - }, [extractImageFilesFromClipboard, processPastedFiles, tryPasteFromTauriClipboard]); + }, [refFiles.length, addRefFiles, withProcessingLock, processFilesWithMd5, t]); + + const { handlePaste } = useReferenceImagePaste({ + isExpanded, + setIsExpanded, + refFilesLength: refFiles.length, + addRefFiles, + withProcessingLock, + processFilesWithMd5, + fileMd5SetRef, + buildPathMd5, + busyErrorMessage: BUSY_ERROR_MESSAGE, + t, + }); // 处理拖拽开始 - 添加视觉反馈 const handleDragEnter = useCallback((e: React.DragEvent) => { @@ -1072,9 +944,9 @@ export function ReferenceImageUpload() { return; } - const isTauri = typeof window !== 'undefined' && Boolean((window as any).__TAURI_INTERNALS__); - const dragBlobSymbol = Symbol.for('__dragImageBlob'); - const cachedData = (window as any)[dragBlobSymbol]; + const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__); + const cachedDragValue = window.__BANANA_DRAG_IMAGE_DATA__; + const cachedData = isCachedDragImageData(cachedDragValue) ? cachedDragValue : null; const cachedCreatedAt = typeof cachedData?.createdAt === 'number' ? cachedData.createdAt : 0; const isCachedFresh = cachedCreatedAt > 0 && Date.now() - cachedCreatedAt < 60_000; @@ -1132,7 +1004,7 @@ export function ReferenceImageUpload() { // 调试日志 // 优先处理缓存的 Blob 数据(避免 CORS / asset:// 导致 URL fetch 失败) - // 使用 Symbol 避免全局变量污染(拖拽源会写入 window[Symbol.for('__dragImageBlob')]) + // 使用固定 window 字段缓存拖拽 Blob,避免 WebView 丢失二进制数据 const hasBlobFlag = (e.dataTransfer.getData('application/x-has-blob') || '').trim(); const canUseCachedBlob = cachedData && @@ -1159,7 +1031,7 @@ export function ReferenceImageUpload() { toast.error(t('refImage.toast.tooLarge')); } } - } catch (err) { + } catch { // 忽略:继续走 URL / 文件兜底 } } @@ -1231,7 +1103,6 @@ export function ReferenceImageUpload() { if (file) { // createImageFileFromUrl 已处理MD5,直接加入已验证列表 validatedFiles.push(file); - } else { } } } catch (error) { @@ -1288,7 +1159,7 @@ export function ReferenceImageUpload() { toast.error(t('refImage.toast.addFailed', { message })); } } - }, [isExpanded, refFiles.length, addRefFiles, withProcessingLock, processFilesWithMd5, createImageFileFromUrl, normalizePathFromUri]); + }, [isExpanded, refFiles.length, addRefFiles, withProcessingLock, processFilesWithMd5, createImageFileFromUrl, t]); // 处理删除文件(同时清理MD5和ObjectURL) // 使用 useConfigStore.getState() 避免依赖 refFiles 数组 @@ -1631,7 +1502,7 @@ export function ReferenceImageUpload() { onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - style={{ WebkitAppRegion: 'no-drag' } as any} + style={noDragStyle} > {/* 标题行 + 折叠按钮 */}
>; + refFilesLength: number; + addRefFiles: (files: File[]) => void; + withProcessingLock: (fn: () => Promise) => Promise; + processFilesWithMd5: (files: File[]) => Promise; + fileMd5SetRef: React.MutableRefObject>; + buildPathMd5: (path: string) => string; + busyErrorMessage: string; + t: TFunction; +}; + +const extractImageFilesFromClipboard = (clipboardData: DataTransfer | null): File[] => { + if (!clipboardData) return []; + const files: File[] = []; + + // 1) items(最常见:截图/复制图片) + const items = Array.from(clipboardData.items); + if (items.length > 0) { + items.forEach((item) => { + if (item.type && item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) files.push(file); + } + }); + } + + // 2) files(部分平台会把图片放在 files 里) + if (clipboardData.files.length > 0) { + Array.from(clipboardData.files).forEach((file) => { + if (file.type && file.type.startsWith('image/')) files.push(file); + }); + } + + return files; +}; + +export function useReferenceImagePaste({ + isExpanded, + setIsExpanded, + refFilesLength, + addRefFiles, + withProcessingLock, + processFilesWithMd5, + fileMd5SetRef, + buildPathMd5, + busyErrorMessage, + t, +}: UseReferenceImagePasteOptions) { + const processPastedFiles = useCallback(async (files: File[]) => { + if (files.length === 0) return; + + // 收起状态也允许粘贴:自动展开,避免“无提示/无响应”的体验 + if (!isExpanded) { + setIsExpanded(true); + } + + await withProcessingLock(async () => { + const remainingSlots = 10 - refFilesLength; + if (remainingSlots <= 0) { + toast.error(t('refImage.toast.full')); + return; + } + + const clipped = files.slice(0, remainingSlots); + if (files.length > remainingSlots) { + toast.error(t('refImage.toast.remainingSlots', { count: remainingSlots })); + } + + const uniqueFiles = await processFilesWithMd5(clipped); + if (uniqueFiles.length > 0) { + addRefFiles(uniqueFiles); + const compressedFiles = uniqueFiles.filter(file => (file as ExtendedFile).__compressed); + if (compressedFiles.length > 0) { + toast.success(t('refImage.toast.addedCompressed', { count: uniqueFiles.length, compressed: compressedFiles.length })); + } else { + toast.success(t('refImage.toast.addedCount', { count: uniqueFiles.length })); + } + } else { + toast.info(t('refImage.toast.exists')); + } + }); + }, [addRefFiles, isExpanded, processFilesWithMd5, refFilesLength, setIsExpanded, t, withProcessingLock]); + + const tryPasteFromTauriClipboard = useCallback(async () => { + const remainingSlots = 10 - refFilesLength; + if (remainingSlots <= 0) { + toast.error(t('refImage.toast.full')); + return; + } + + // 收起状态也允许粘贴:自动展开 + if (!isExpanded) { + setIsExpanded(true); + } + + try { + const { invoke } = await import('@tauri-apps/api/core'); + const path = await invoke('read_image_from_clipboard'); + const imagePath = (path || '').trim(); + if (!imagePath) return; // 剪贴板里没有图片:静默忽略 + + const md5Key = buildPathMd5(imagePath); + if (fileMd5SetRef.current.has(md5Key)) { + toast.info(t('refImage.toast.exists')); + return; + } + + const name = imagePath.split(/[/\\]/).pop() || `clipboard-${Date.now()}.png`; + const file = new File([], name, { type: 'image/png' }) as ExtendedFile; + file.__path = imagePath; + file.__md5 = md5Key; + + addRefFiles([file]); + toast.success(t('refImage.toast.addedOne')); + } catch (err) { + // 原生读取失败:静默忽略,避免影响正常文本粘贴体验 + console.warn('[ReferenceImageUpload] read_image_from_clipboard failed:', err); + } + }, [addRefFiles, buildPathMd5, fileMd5SetRef, isExpanded, refFilesLength, setIsExpanded, t]); + + const handlePaste = useCallback(async (event: React.ClipboardEvent) => { + const files = extractImageFilesFromClipboard(event.clipboardData || null); + if (files.length > 0) { + event.preventDefault(); + event.stopPropagation(); + try { + await processPastedFiles(files); + } catch (error) { + if (error instanceof Error && error.message === busyErrorMessage) { + toast.info(t('refImage.toast.busy')); + } else { + console.error('Paste image failed:', error); + const message = error instanceof Error ? error.message : t('refImage.toast.unknown'); + toast.error(t('refImage.toast.pasteFailed', { message })); + } + } + return; + } + + const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__); + if (!isTauri) return; + + // 如果用户在粘贴纯文本(且当前在输入框内),不要触发原生读取,避免拖慢输入体验 + const plain = (event.clipboardData?.getData('text/plain') || '').trim(); + const target = event.target; + if ( + plain && + target instanceof Element && + (target.matches('input, textarea, [contenteditable="true"], [contenteditable=""]') || + Boolean(target.closest('[contenteditable="true"], [contenteditable=""]'))) + ) return; + + // 兜底:Tauri 打包环境下 Web ClipboardData 可能拿不到图片数据,尝试原生读取 + void tryPasteFromTauriClipboard(); + }, [busyErrorMessage, processPastedFiles, t, tryPasteFromTauriClipboard]); + + // 全局 paste 捕获:不要求用户必须聚焦参考图区域 + useEffect(() => { + const onPaste = (event: ClipboardEvent) => { + if (!event.clipboardData) return; + + const files = extractImageFilesFromClipboard(event.clipboardData); + if (files.length > 0) { + event.preventDefault(); + event.stopPropagation(); + void processPastedFiles(files); + return; + } + + const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__); + if (!isTauri) return; + + const plain = (event.clipboardData.getData('text/plain') || '').trim(); + const target = event.target; + if ( + plain && + target instanceof Element && + (target.matches('input, textarea, [contenteditable="true"], [contenteditable=""]') || + Boolean(target.closest('[contenteditable="true"], [contenteditable=""]'))) + ) return; + + void tryPasteFromTauriClipboard(); + }; + + window.addEventListener('paste', onPaste, true); + return () => { + window.removeEventListener('paste', onPaste, true); + }; + }, [processPastedFiles, tryPasteFromTauriClipboard]); + + return { handlePaste }; +} diff --git a/desktop/src/components/GenerateArea/BatchActions.tsx b/desktop/src/components/GenerateArea/BatchActions.tsx index 75b7ac6..5663bef 100644 --- a/desktop/src/components/GenerateArea/BatchActions.tsx +++ b/desktop/src/components/GenerateArea/BatchActions.tsx @@ -1,5 +1,6 @@ -import React, { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect } from 'react'; import { CheckSquare, Square, Download, Trash2, Loader2 } from 'lucide-react'; +import { useShallow } from 'zustand/react/shallow'; import { useGenerateStore } from '../../store/generateStore'; import { useGenerateDisplayImages } from '../../hooks/useGenerateDisplayImages'; import { Button } from '../common/Button'; @@ -10,7 +11,14 @@ import { useTranslation } from 'react-i18next'; export function BatchActions() { const { t } = useTranslation(); const images = useGenerateDisplayImages(); - const { selectedIds, selectAll, clearSelection, clearImages } = useGenerateStore(); + const { selectedIds, selectAll, clearSelection, clearImages } = useGenerateStore( + useShallow((s) => ({ + selectedIds: s.selectedIds, + selectAll: s.selectAll, + clearSelection: s.clearSelection, + clearImages: s.clearImages + })) + ); const [isExporting, setIsExporting] = useState(false); const objectUrlRef = useRef(null); // 记录 ObjectURL diff --git a/desktop/src/components/Settings/ProviderConnectionFields.tsx b/desktop/src/components/Settings/ProviderConnectionFields.tsx new file mode 100644 index 0000000..547188a --- /dev/null +++ b/desktop/src/components/Settings/ProviderConnectionFields.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { Eye, EyeOff, Globe, Key } from 'lucide-react'; +import { Input } from '../common/Input'; + +interface ProviderConnectionFieldsProps { + baseUrl: string; + onBaseUrlChange: (value: string) => void; + baseUrlPlaceholder: string; + apiKey: string; + onApiKeyChange: (value: string) => void; + apiKeyPlaceholder: string; + showApiKey: boolean; + onToggleApiKey: () => void; + recommendedLabel: string; + yunwuLabel: string; + onOpenYunwu: () => void; + warning?: React.ReactNode; + baseUrlHint?: React.ReactNode; + apiKeyHint?: React.ReactNode; +} + +export function ProviderConnectionFields({ + baseUrl, + onBaseUrlChange, + baseUrlPlaceholder, + apiKey, + onApiKeyChange, + apiKeyPlaceholder, + showApiKey, + onToggleApiKey, + recommendedLabel, + yunwuLabel, + onOpenYunwu, + warning, + baseUrlHint, + apiKeyHint +}: ProviderConnectionFieldsProps) { + return ( + <> +
+
+ + + {recommendedLabel} + + +
+ { + onBaseUrlChange(e.target.value); + }} + placeholder={baseUrlPlaceholder} + className="h-10 bg-slate-100 text-slate-900 font-medium rounded-2xl text-sm px-5 focus:bg-white border border-slate-200 transition-all shadow-none" + /> + {warning} + {baseUrlHint} +
+ +
+ +
+ { + onApiKeyChange(e.target.value); + }} + placeholder={apiKeyPlaceholder} + className="h-10 bg-slate-100 text-slate-900 font-medium rounded-2xl text-sm px-5 pr-14 focus:bg-white border border-slate-200 transition-all shadow-none" + /> + +
+ {apiKeyHint} +
+ + ); +} diff --git a/desktop/src/components/Settings/SettingsModal.tsx b/desktop/src/components/Settings/SettingsModal.tsx index d91a2fe..48016e0 100644 --- a/desktop/src/components/Settings/SettingsModal.tsx +++ b/desktop/src/components/Settings/SettingsModal.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useMemo } from 'react'; import { Eye, EyeOff, Key, Globe, Box, Save, Loader2, FileText, FolderOpen, Copy, RefreshCw, Languages, MessageSquare, Github, ScanEye, HelpCircle, Image as ImageIcon, Bell } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { useShallow } from 'zustand/react/shallow'; import { useConfigStore } from '../../store/configStore'; import { Input } from '../common/Input'; import { Select } from '../common/Select'; @@ -11,12 +12,13 @@ import { getProviders, updateProviderConfig, ProviderConfig } from '../../servic import { toast } from '../../store/toastStore'; import { getDiagnosticVerbose, setDiagnosticVerbose } from '../../utils/diagnosticLogger'; import { useUpdaterStore } from '../../store/updaterStore'; -import i18n, { DEFAULT_LANGUAGE } from '../../i18n'; +import i18n, { changeAppLanguage, DEFAULT_LANGUAGE } from '../../i18n'; import { getSystemLocale } from '../../i18n/systemLocale'; import appIcon from '../../assets/app-icon.png'; import { getDefaultImageModelForProvider, getImageModelOptions, VISION_MODEL_OPTIONS, CUSTOM_MODEL_VALUE } from '../../store/configStore'; import { getPromptOptimizeConfigIssue } from '../../utils/promptOptimizeConfig'; import { ensureNotificationPermission, sendTestSystemNotification } from '../../hooks/useGenerationNotifications'; +import { ProviderConnectionFields } from './ProviderConnectionFields'; const CHAT_PROVIDER_OPTIONS = [ { value: 'gemini-chat', label: 'Gemini(/v1beta)', defaultBase: 'https://generativelanguage.googleapis.com' }, @@ -183,7 +185,63 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { setLanguage, setLanguageResolved, setShowOnboarding - } = useConfigStore(); + } = useConfigStore( + useShallow((s) => ({ + imageProvider: s.imageProvider, + setImageProvider: s.setImageProvider, + imageApiKey: s.imageApiKey, + setImageApiKey: s.setImageApiKey, + imageApiBaseUrl: s.imageApiBaseUrl, + setImageApiBaseUrl: s.setImageApiBaseUrl, + imageModel: s.imageModel, + setImageModel: s.setImageModel, + imageTimeoutSeconds: s.imageTimeoutSeconds, + setImageTimeoutSeconds: s.setImageTimeoutSeconds, + imageMaxRetries: s.imageMaxRetries, + setImageMaxRetries: s.setImageMaxRetries, + enableRefImageCompression: s.enableRefImageCompression, + setEnableRefImageCompression: s.setEnableRefImageCompression, + visionProvider: s.visionProvider, + setVisionProvider: s.setVisionProvider, + visionApiBaseUrl: s.visionApiBaseUrl, + setVisionApiBaseUrl: s.setVisionApiBaseUrl, + visionApiKey: s.visionApiKey, + setVisionApiKey: s.setVisionApiKey, + visionModel: s.visionModel, + setVisionModel: s.setVisionModel, + visionTimeoutSeconds: s.visionTimeoutSeconds, + setVisionTimeoutSeconds: s.setVisionTimeoutSeconds, + visionMaxRetries: s.visionMaxRetries, + setVisionMaxRetries: s.setVisionMaxRetries, + setVisionSyncedConfig: s.setVisionSyncedConfig, + chatProvider: s.chatProvider, + setChatProvider: s.setChatProvider, + chatApiBaseUrl: s.chatApiBaseUrl, + setChatApiBaseUrl: s.setChatApiBaseUrl, + chatApiKey: s.chatApiKey, + setChatApiKey: s.setChatApiKey, + chatModel: s.chatModel, + setChatModel: s.setChatModel, + chatTimeoutSeconds: s.chatTimeoutSeconds, + setChatTimeoutSeconds: s.setChatTimeoutSeconds, + chatMaxRetries: s.chatMaxRetries, + setChatMaxRetries: s.setChatMaxRetries, + setChatSyncedConfig: s.setChatSyncedConfig, + defaultPromptOptimizeMode: s.defaultPromptOptimizeMode, + setDefaultPromptOptimizeMode: s.setDefaultPromptOptimizeMode, + enableSystemNotifications: s.enableSystemNotifications, + setEnableSystemNotifications: s.setEnableSystemNotifications, + notifyOnlyWhenBackground: s.notifyOnlyWhenBackground, + setNotifyOnlyWhenBackground: s.setNotifyOnlyWhenBackground, + notifyOnFailure: s.notifyOnFailure, + setNotifyOnFailure: s.setNotifyOnFailure, + language: s.language, + languageResolved: s.languageResolved, + setLanguage: s.setLanguage, + setLanguageResolved: s.setLanguageResolved, + setShowOnboarding: s.setShowOnboarding + })) + ); const [activeTab, setActiveTab] = useState('image'); const [showImageKey, setShowImageKey] = useState(false); @@ -605,7 +663,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { const resolved = resolveSystemLanguage(systemLocale); setLanguageResolved(resolved); if (i18n.language !== resolved) { - void i18n.changeLanguage(resolved); + void changeAppLanguage(resolved); } return; } @@ -615,7 +673,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) { setLanguageResolved(null); } if (i18n.language !== nextLanguage) { - void i18n.changeLanguage(nextLanguage); + void changeAppLanguage(nextLanguage); } }; @@ -1061,57 +1119,24 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
- {/* API Base URL */} -
-
- - - {t('settings.provider.recommended')} - - -
- setImageApiBaseUrl(e.target.value)} - placeholder="https://generativelanguage.googleapis.com" - className="h-10 bg-slate-100 text-slate-900 font-medium rounded-2xl text-sm px-5 focus:bg-white border border-slate-200 transition-all shadow-none" - /> - {imageBaseWarn && } -
- - {/* API Key */} -
- -
- setImageApiKey(e.target.value)} - placeholder="sk-******************" - className="h-10 bg-slate-100 text-slate-900 font-medium rounded-2xl text-sm px-5 pr-14 focus:bg-white border border-slate-200 transition-all shadow-none" - /> - -
-
+ { + setShowImageKey(!showImageKey); + }} + recommendedLabel={t('settings.provider.recommended')} + yunwuLabel={t('settings.provider.yunwu')} + onOpenYunwu={() => { + void handleOpenYunwu(); + }} + warning={imageBaseWarn ? : undefined} + /> {/* Model Name */}
diff --git a/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx b/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx index 4981481..9e80b5e 100644 --- a/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx +++ b/desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx @@ -17,8 +17,6 @@ import { Maximize2, MessageCircle, Printer, - RefreshCw, - Search, ShoppingBag, Smile, Sparkles, @@ -28,7 +26,6 @@ import { ZoomIn, ZoomOut } from 'lucide-react'; -import { Input } from '../common/Input'; import { Button } from '../common/Button'; import { Modal } from '../common/Modal'; import { useConfigStore } from '../../store/configStore'; @@ -47,6 +44,7 @@ import { templateLabelKeys, TEMPLATE_ALL_VALUE } from '../../data/templateMarket'; +import { TemplateMarketFilters, type ActiveTemplateFilter } from './TemplateMarketFilters'; const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); const hashString = async (value: string) => { @@ -73,6 +71,35 @@ const mimeToExtension = (mime: string) => { return 'jpg'; }; const ALL_VALUE = TEMPLATE_ALL_VALUE; +const TEMPLATE_GRID_GAP = 20; +const TEMPLATE_GRID_MIN_CARD_WIDTH = 180; +const TEMPLATE_GRID_CARD_HEIGHT = 320; +const TEMPLATE_GRID_COLUMNS = { + MIN: 2, + MEDIUM: 3, + LARGE: 4 +} as const; +const TEMPLATE_GRID_BREAKPOINTS = { + MEDIUM: 768, + LARGE: 1280 +} as const; + +const getTemplateColumnCount = (containerWidth: number, viewportWidth: number | undefined) => { + const basis = viewportWidth ?? containerWidth; + let count: number = TEMPLATE_GRID_COLUMNS.MIN; + if (basis >= TEMPLATE_GRID_BREAKPOINTS.LARGE) { + count = TEMPLATE_GRID_COLUMNS.LARGE; + } else if (basis >= TEMPLATE_GRID_BREAKPOINTS.MEDIUM) { + count = TEMPLATE_GRID_COLUMNS.MEDIUM; + } + + while (count > TEMPLATE_GRID_COLUMNS.MIN) { + const requiredWidth = count * TEMPLATE_GRID_MIN_CARD_WIDTH + (count - 1) * TEMPLATE_GRID_GAP; + if (containerWidth >= requiredWidth) break; + count -= 1; + } + return count; +}; const buildDefaultTemplateImage = (label: string) => `data:image/svg+xml;utf8,${encodeURIComponent( ` @@ -104,7 +131,7 @@ const fallbackMeta: TemplateMeta = { const FILTER_LABEL_KEYS: Record = templateLabelKeys; -const getFilterLabel = (value: string, translate: (key: string, options?: any) => string) => { +const getFilterLabel = (value: string, translate: (key: string, options?: Record) => string) => { const key = FILTER_LABEL_KEYS[value]; return key ? translate(key) : value; }; @@ -178,7 +205,7 @@ const formatSourceName = (name: string) => (name.startsWith('@') ? name : `@${na const openExternalUrl = async (url: string) => { if (!url) return; - if ((window as any).__TAURI_INTERNALS__) { + if (window.__TAURI_INTERNALS__) { try { const { openUrl } = await import('@tauri-apps/plugin-opener'); await openUrl(url); @@ -190,45 +217,6 @@ const openExternalUrl = async (url: string) => { window.open(url, '_blank', 'noopener,noreferrer'); }; -const ActiveFilterChip = ({ - label, - onClear -}: { - label: string; - onClear: () => void; -}) => ( - -); - -const FilterChip = ({ - label, - active, - onClick -}: { - label: string; - active: boolean; - onClick: () => void; -}) => ( - -); - const buildSearchText = (item: TemplateItem) => { const tags = item.tags ? item.tags.join(' ') : ''; const channels = Array.isArray(item.channels) ? item.channels.join(' ') : ''; @@ -416,7 +404,7 @@ const TemplatePreviewModal = ({ } const blob = await response.blob(); - const isTauri = typeof window !== 'undefined' && Boolean((window as any).__TAURI_INTERNALS__); + const isTauri = typeof window !== 'undefined' && Boolean(window.__TAURI_INTERNALS__); if (isTauri) { try { const { invoke } = await import('@tauri-apps/api/core'); @@ -436,7 +424,7 @@ const TemplatePreviewModal = ({ } } - const ClipboardItemCtor = (window as any).ClipboardItem as typeof ClipboardItem | undefined; + const ClipboardItemCtor = typeof ClipboardItem !== 'undefined' ? ClipboardItem : undefined; if (ClipboardItemCtor && navigator.clipboard?.write) { await navigator.clipboard.write([new ClipboardItemCtor({ [blob.type || 'image/png']: blob })]); toast.success(t('toast.copyImageSuccess')); @@ -1049,6 +1037,108 @@ const TemplateCard = React.memo(function TemplateCard({ ); }); +const VirtualTemplateGrid = ({ + templates, + applyingId, + onPreview, + onApply, + isFiltering, + scrollTop, + viewportHeight +}: { + templates: TemplateItem[]; + applyingId: string | null; + onPreview: (item: TemplateItem) => void; + onApply: (item: TemplateItem) => void; + isFiltering: boolean; + scrollTop: number; + viewportHeight: number; +}) => { + const wrapperRef = useRef(null); + const [width, setWidth] = useState(0); + + useLayoutEffect(() => { + const element = wrapperRef.current; + if (!element) return; + + const updateWidth = () => { + setWidth(element.clientWidth); + }; + updateWidth(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateWidth); + return () => { + window.removeEventListener('resize', updateWidth); + }; + } + + const observer = new ResizeObserver(updateWidth); + observer.observe(element); + return () => { + observer.disconnect(); + }; + }, []); + + const viewportWidth = + typeof window !== 'undefined' + ? window.innerWidth || document.documentElement.clientWidth + : width; + const columnCount = width > 0 ? getTemplateColumnCount(width, viewportWidth) : 2; + const columnWidth = Math.max( + TEMPLATE_GRID_MIN_CARD_WIDTH, + Math.floor((Math.max(width, 1) - TEMPLATE_GRID_GAP * (columnCount - 1)) / columnCount) + ); + const rowHeight = TEMPLATE_GRID_CARD_HEIGHT + TEMPLATE_GRID_GAP; + const rowCount = Math.ceil(templates.length / columnCount); + const totalHeight = rowCount * rowHeight; + const gridOffsetTop = wrapperRef.current?.offsetTop ?? 0; + const relativeScrollTop = Math.max(0, scrollTop - gridOffsetTop); + const overscanRows = 2; + const startRow = Math.max(0, Math.floor(relativeScrollTop / rowHeight) - overscanRows); + const endRow = Math.min( + rowCount - 1, + Math.ceil((relativeScrollTop + viewportHeight) / rowHeight) + overscanRows + ); + const visibleCells: Array<{ item: TemplateItem; index: number; rowIndex: number; columnIndex: number }> = []; + + if (width > 0 && rowCount > 0 && endRow >= startRow) { + for (let rowIndex = startRow; rowIndex <= endRow; rowIndex += 1) { + const rowStartIndex = rowIndex * columnCount; + templates.slice(rowStartIndex, rowStartIndex + columnCount).forEach((item, columnIndex) => { + visibleCells.push({ item, index: rowStartIndex + columnIndex, rowIndex, columnIndex }); + }); + } + } + + return ( +
+ {visibleCells.map(({ item, index, rowIndex, columnIndex }) => ( +
+ +
+ ))} +
+ ); +}; + export function TemplateMarketDrawer({ onOpenChange }: { @@ -1084,15 +1174,18 @@ export function TemplateMarketDrawer({ const [isLoading, setIsLoading] = useState(false); const [loadError, setLoadError] = useState(null); const [isFiltering, setIsFiltering] = useState(false); + const [listScrollTop, setListScrollTop] = useState(0); + const [listViewportHeight, setListViewportHeight] = useState(0); const dragStartRef = useRef(0); const toastOnceRef = useRef(false); const previousOverflowRef = useRef(null); const previousOverscrollRef = useRef(null); const requestIdRef = useRef(0); - const listRef = useRef(null); const templateDataRef = useRef(templateData); + const listRef = useRef(null); const templateSourceRef = useRef(templateSource); const scrollTopRef = useRef(0); + const filteringTimerRef = useRef | null>(null); const previousTabRef = useRef<'generate' | 'history'>('generate'); const deferredSearch = useDeferredValue(search); @@ -1137,7 +1230,7 @@ export function TemplateMarketDrawer({ }, [isDormant, deferredSearch, channel, material, industry, ratio, templateData.items, searchIndex]); const activeFilters = useMemo(() => { - const filters: { label: string; onClear: () => void }[] = []; + const filters: ActiveTemplateFilter[] = []; if (search.trim()) { filters.push({ label: t('templateMarket.active.search', { keyword: search.trim() }), onClear: () => setSearch('') }); } @@ -1166,6 +1259,7 @@ export function TemplateMarketDrawer({ setRatio(ALL_VALUE); }; + const fetchTemplates = useCallback(async (fromUser = false) => { const requestId = ++requestIdRef.current; setIsLoading(true); @@ -1216,11 +1310,63 @@ export function TemplateMarketDrawer({ fetchTemplates(); }, [fetchTemplates]); + + useLayoutEffect(() => { + if (!isOpen) return; + const container = listRef.current; + if (!container) return; + + const updateViewport = () => { + setListViewportHeight(container.clientHeight); + }; + updateViewport(); + + if (typeof ResizeObserver === 'undefined') { + window.addEventListener('resize', updateViewport); + return () => { + window.removeEventListener('resize', updateViewport); + }; + } + + const observer = new ResizeObserver(updateViewport); + observer.observe(container); + return () => { + observer.disconnect(); + }; + }, [isOpen]); + + useLayoutEffect(() => { + if (!isOpen || isDormant) return; + const container = listRef.current; + if (!container) return; + const top = scrollTopRef.current; + if (top <= 0) return; + requestAnimationFrame(() => { + container.scrollTop = top; + setListScrollTop(top); + }); + }, [isOpen, isDormant, filteredTemplates.length]); + useEffect(() => { - if (isDormant || isFiltering) return; + if (filteringTimerRef.current) { + clearTimeout(filteringTimerRef.current); + filteringTimerRef.current = null; + } + if (isDormant) { + setIsFiltering(false); + return; + } setIsFiltering(true); - const timer = setTimeout(() => setIsFiltering(false), 180); - return () => clearTimeout(timer); + filteringTimerRef.current = setTimeout(() => { + setIsFiltering(false); + filteringTimerRef.current = null; + }, 180); + return () => { + if (filteringTimerRef.current) { + clearTimeout(filteringTimerRef.current); + filteringTimerRef.current = null; + } + }; }, [isDormant, deferredSearch, channel, material, industry, ratio, templateData.items]); @@ -1239,17 +1385,6 @@ export function TemplateMarketDrawer({ return () => window.removeEventListener('keydown', handleKeyDown); }, [isOpen, previewTemplate]); - useLayoutEffect(() => { - if (!isOpen || isDormant) return; - const container = listRef.current; - if (!container) return; - const top = scrollTopRef.current; - if (top <= 0) return; - requestAnimationFrame(() => { - container.scrollTop = top; - }); - }, [isOpen, isDormant, filteredTemplates.length]); - useEffect(() => { const element = document.documentElement; if (isOpen) { @@ -1294,7 +1429,6 @@ export function TemplateMarketDrawer({ nextTab: 'generate' | 'history', options?: { deferClose?: boolean } ) => { - scrollTopRef.current = listRef.current?.scrollTop ?? 0; setTab(nextTab); const doClose = () => setIsOpen(false); if (options?.deferClose) { @@ -1428,110 +1562,38 @@ export function TemplateMarketDrawer({
)} -
-
-
- - setSearch(e.target.value)} - placeholder={t('templateMarket.searchPlaceholder')} - className="pl-10 bg-white/80" - /> -
-
- {t('templateMarket.activeFilters.title')} - {hasActiveFilters ? ( - <> - {activeFilters.map((filter) => ( - - ))} - - - ) : ( - {t('templateMarket.activeFilters.empty')} - )} -
- -
-
-

{t('templateMarket.filters.channel')}

-
- {normalizedMeta.channels.map((item) => ( - setChannel(item)} - /> - ))} -
-
-
-

{t('templateMarket.filters.material')}

-
- {normalizedMeta.materials.map((item) => ( - setMaterial(item)} - /> - ))} -
-
-
-

{t('templateMarket.filters.industry')}

-
- {normalizedMeta.industries.map((item) => ( - setIndustry(item)} - /> - ))} -
-
-
-

{t('templateMarket.filters.ratio')}

-
- {normalizedMeta.ratios.map((item) => ( - setRatio(item)} - /> - ))} -
-
-
- -
-
-

- {isLoading ? t('templateMarket.list.loading') : t('templateMarket.list.count', { count: filteredTemplates.length })} -

-
- -
-
- -
+
{ + const nextScrollTop = event.currentTarget.scrollTop; + scrollTopRef.current = nextScrollTop; + setListScrollTop(nextScrollTop); + }} + > + { + void fetchTemplates(true); + }} + /> + +
{isDormant ? (
) : ( -
-
event.preventDefault()} - > - {filteredTemplates.map((item) => ( - - ))} -
+
{ + event.preventDefault(); + }} + > + { + setPreviewTemplate(template); + }} + onApply={(template) => { + void applyTemplate(template); + }} + isFiltering={isFiltering} + scrollTop={listScrollTop} + viewportHeight={listViewportHeight} + />
)}
@@ -1591,9 +1657,15 @@ export function TemplateMarketDrawer({ setPreviewTemplate(null)} - onUse={applyTemplate} + onTemplateChange={(template) => { + setPreviewTemplate(template); + }} + onClose={() => { + setPreviewTemplate(null); + }} + onUse={(template) => { + void applyTemplate(template); + }} applying={Boolean(applyingId)} /> diff --git a/desktop/src/components/TemplateMarket/TemplateMarketFilters.tsx b/desktop/src/components/TemplateMarket/TemplateMarketFilters.tsx new file mode 100644 index 0000000..c5ecf51 --- /dev/null +++ b/desktop/src/components/TemplateMarket/TemplateMarketFilters.tsx @@ -0,0 +1,208 @@ +import { RefreshCw, Search, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Input } from '../common/Input'; + +export type TemplateFilterMeta = { + channels: string[]; + materials: string[]; + industries: string[]; + ratios: string[]; +}; + +export type ActiveTemplateFilter = { + label: string; + onClear: () => void; +}; + +type TemplateMarketFiltersProps = { + search: string; + onSearchChange: (value: string) => void; + activeFilters: ActiveTemplateFilter[]; + onClearAllFilters: () => void; + meta: TemplateFilterMeta; + channel: string; + material: string; + industry: string; + ratio: string; + onChannelChange: (value: string) => void; + onMaterialChange: (value: string) => void; + onIndustryChange: (value: string) => void; + onRatioChange: (value: string) => void; + formatFilterLabel: (value: string) => string; + isLoading: boolean; + resultCount: number; + onRefresh: () => void; +}; + +const ActiveFilterChip = ({ + label, + onClear +}: ActiveTemplateFilter) => ( + +); + +const FilterChip = ({ + label, + active, + onClick +}: { + label: string; + active: boolean; + onClick: () => void; +}) => ( + +); + +export function TemplateMarketFilters({ + search, + onSearchChange, + activeFilters, + onClearAllFilters, + meta, + channel, + material, + industry, + ratio, + onChannelChange, + onMaterialChange, + onIndustryChange, + onRatioChange, + formatFilterLabel, + isLoading, + resultCount, + onRefresh +}: TemplateMarketFiltersProps) { + const { t } = useTranslation(); + const hasActiveFilters = activeFilters.length > 0; + + return ( +
+
+ + { + onSearchChange(e.target.value); + }} + placeholder={t('templateMarket.searchPlaceholder')} + className="pl-10 bg-white/80" + /> +
+
+ {t('templateMarket.activeFilters.title')} + {hasActiveFilters ? ( + <> + {activeFilters.map((filter) => ( + + ))} + + + ) : ( + {t('templateMarket.activeFilters.empty')} + )} +
+ +
+
+

{t('templateMarket.filters.channel')}

+
+ {meta.channels.map((item) => ( + { + onChannelChange(item); + }} + /> + ))} +
+
+
+

{t('templateMarket.filters.material')}

+
+ {meta.materials.map((item) => ( + { + onMaterialChange(item); + }} + /> + ))} +
+
+
+

{t('templateMarket.filters.industry')}

+
+ {meta.industries.map((item) => ( + { + onIndustryChange(item); + }} + /> + ))} +
+
+
+

{t('templateMarket.filters.ratio')}

+
+ {meta.ratios.map((item) => ( + { + onRatioChange(item); + }} + /> + ))} +
+
+
+ +
+
+

+ {isLoading ? t('templateMarket.list.loading') : t('templateMarket.list.count', { count: resultCount })} +

+
+ +
+
+ ); +} diff --git a/desktop/src/components/common/Toast.tsx b/desktop/src/components/common/Toast.tsx index 0f97e1e..3d7e7d2 100644 --- a/desktop/src/components/common/Toast.tsx +++ b/desktop/src/components/common/Toast.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { createPortal } from 'react-dom'; +import { useShallow } from 'zustand/react/shallow'; import { useToastStore, ToastType } from '../../store/toastStore'; import { CheckCircle2, AlertCircle, Info, AlertTriangle, X } from 'lucide-react'; import { cn } from './Button'; @@ -19,7 +20,12 @@ const bgMap: Record = { }; export function ToastContainer() { - const { toasts, removeToast } = useToastStore(); + const { toasts, removeToast } = useToastStore( + useShallow((s) => ({ + toasts: s.toasts, + removeToast: s.removeToast + })) + ); // 使用 createPortal 渲染到 body,确保 Toast 始终在最顶层 // z-index 使用 2147483647(32位有符号整数最大值),确保不会被任何元素遮挡 diff --git a/desktop/src/i18n/index.ts b/desktop/src/i18n/index.ts index 53640cb..bbef7be 100644 --- a/desktop/src/i18n/index.ts +++ b/desktop/src/i18n/index.ts @@ -1,9 +1,6 @@ import i18n from 'i18next'; import { initReactI18next } from 'react-i18next'; import zhCN from './locales/zh-CN.json'; -import enUS from './locales/en-US.json'; -import jaJP from './locales/ja-JP.json'; -import koKR from './locales/ko-KR.json'; import { getSystemLocale } from './systemLocale'; export const SUPPORTED_LANGUAGES = ['zh-CN', 'en-US', 'ja-JP', 'ko-KR'] as const; @@ -27,6 +24,23 @@ type StoredLanguageState = { languageResolved?: string | null; }; +type TranslationResource = Record; + +const loadLanguageResource = (lang: SupportedLanguage): Promise => { + switch (lang) { + case 'zh-CN': + return Promise.resolve(zhCN); + case 'en-US': + return import('./locales/en-US.json').then((module) => module.default); + case 'ja-JP': + return import('./locales/ja-JP.json').then((module) => module.default); + case 'ko-KR': + return import('./locales/ko-KR.json').then((module) => module.default); + } +}; + +const loadedLanguages = new Set([DEFAULT_LANGUAGE]); + const getStoredLanguageState = (): StoredLanguageState | null => { if (typeof localStorage === 'undefined') return null; try { @@ -44,9 +58,21 @@ const getStoredLanguageState = (): StoredLanguageState | null => { const resources = { 'zh-CN': { translation: zhCN }, - 'en-US': { translation: enUS }, - 'ja-JP': { translation: jaJP }, - 'ko-KR': { translation: koKR } +}; + +const ensureLanguageResource = async (lang: SupportedLanguage): Promise => { + if (loadedLanguages.has(lang)) return; + + const translation = await loadLanguageResource(lang); + i18n.addResourceBundle(lang, 'translation', translation, true, true); + loadedLanguages.add(lang); +}; + +export const changeAppLanguage = async (lang: string): Promise => { + const normalized = normalizeLanguage(lang) || DEFAULT_LANGUAGE; + await ensureLanguageResource(normalized); + await i18n.changeLanguage(normalized); + return i18n.language as SupportedLanguage; }; const updateDocumentLanguage = (lang: string) => { @@ -55,7 +81,7 @@ const updateDocumentLanguage = (lang: string) => { document.title = i18n.t('app.title'); } - if (typeof window !== 'undefined' && (window as any).__TAURI_INTERNALS__) { + if (typeof window !== 'undefined' && window.__TAURI_INTERNALS__) { import('@tauri-apps/api/window') .then(({ getCurrentWindow }) => getCurrentWindow().setTitle(i18n.t('app.title'))) .catch(() => undefined); @@ -95,6 +121,8 @@ export const initI18n = async (): Promise => { interpolation: { escapeValue: false } }); + await changeAppLanguage(resolved); + updateDocumentLanguage(i18n.language); i18n.on('languageChanged', updateDocumentLanguage); diff --git a/desktop/src/services/api.ts b/desktop/src/services/api.ts index 875d727..2d2168e 100644 --- a/desktop/src/services/api.ts +++ b/desktop/src/services/api.ts @@ -1,5 +1,6 @@ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'; import { ApiResponse } from '../types'; +import { getDiagnosticVerbose } from '../utils/diagnosticLogger'; export interface ApiRequestConfig extends AxiosRequestConfig { __returnResponse?: boolean; @@ -20,6 +21,12 @@ const isStorageRelativePath = (p: string) => { const isPosixAbsolutePath = (p: string) => p.startsWith('/') && !isStorageRelativePath(p); const looksLikeAbsolutePath = (p: string) => isPosixAbsolutePath(p) || isWindowsAbsolutePath(p); +const logImageUrlDiagnostic = (...args: unknown[]) => { + if (getDiagnosticVerbose()) { + console.log(...args); + } +}; + const normalizeAssetAbsolutePath = (rawPath: string) => { let normalized = normalizeSlashes(rawPath); @@ -348,7 +355,7 @@ export const getImageUrl = (path: string) => { try { const absolutePath = normalizeAssetAbsolutePath(fileUrlPath || trimmed); const url = convertFileSrcSync!(absolutePath); - console.log('[getImageUrl] Converted absolute path to asset URL:', url, 'from:', absolutePath); + logImageUrlDiagnostic('[getImageUrl] Converted absolute path to asset URL:', url, 'from:', absolutePath); return url; } catch (err) { console.error('[getImageUrl] Failed to convert absolute path to asset URL:', err); @@ -369,7 +376,7 @@ export const getImageUrl = (path: string) => { // 使用 Tauri 提供的 convertFileSrc 将绝对路径转为 asset:// 协议 URL const url = convertFileSrcSync!(absolutePath); - console.log('[getImageUrl] Converted to asset URL:', url, 'from:', absolutePath); + logImageUrlDiagnostic('[getImageUrl] Converted to asset URL:', url, 'from:', absolutePath); return url; } catch (err) { console.error('[getImageUrl] Failed to convert local path to asset URL:', err); @@ -385,7 +392,7 @@ export const getImageUrl = (path: string) => { const normalizedPath = normalizedInputPath.startsWith('/') ? normalizedInputPath : `/${normalizedInputPath}`; const url = `${baseHost}${normalizedPath}`; - console.log('[getImageUrl] HTTP Fallback URL:', url); + logImageUrlDiagnostic('[getImageUrl] HTTP Fallback URL:', url); return url; }; diff --git a/desktop/src/services/configApi.ts b/desktop/src/services/configApi.ts index 3bab4bb..b71b024 100644 --- a/desktop/src/services/configApi.ts +++ b/desktop/src/services/configApi.ts @@ -1,18 +1,8 @@ import api from './api'; +import type { ProviderConfig } from './providerApi'; -export interface ProviderConfig { - provider_name: string; - display_name: string; - api_key: string; - api_base: string; - enabled: boolean; - model_id?: string; - models?: string; -} - -// 更新 Provider 配置 -export const updateProviderConfig = (config: Partial) => - api.post('/providers/config', config); +export type { ProviderConfig } from './providerApi'; +export { updateProviderConfig } from './providerApi'; // 获取当前 Provider 配置 (后端目前没有直接 GET 接口,这里先预留) export const getProviderConfigs = () => diff --git a/desktop/src/services/providerApi.ts b/desktop/src/services/providerApi.ts index 6f04c90..2e76922 100644 --- a/desktop/src/services/providerApi.ts +++ b/desktop/src/services/providerApi.ts @@ -1,21 +1,21 @@ import api from './api'; export interface ProviderConfig { - provider_name: string; - display_name: string; - api_base: string; - api_key: string; - enabled: boolean; - model_id?: string; - models?: string; - timeout_seconds?: number; - max_retries?: number; + provider_name: string; + display_name: string; + api_base: string; + api_key: string; + enabled: boolean; + model_id?: string; + models?: string; + timeout_seconds?: number; + max_retries?: number; } export const getProviders = async (): Promise => { - return api.get('/providers'); + return api.get('/providers'); }; -export const updateProviderConfig = async (config: ProviderConfig): Promise => { - return api.post('/providers/config', config); +export const updateProviderConfig = async (config: Partial): Promise => { + return api.post('/providers/config', config); }; diff --git a/desktop/src/store/historyStore.ts b/desktop/src/store/historyStore.ts index 070efd1..5c4e8af 100644 --- a/desktop/src/store/historyStore.ts +++ b/desktop/src/store/historyStore.ts @@ -33,6 +33,10 @@ interface HistoryState { let latestHistoryRequestId = 0; type HistoryTaskUpdate = Partial & { id: string }; +type PersistedHistoryState = Pick; + +const HISTORY_PAGE_SIZE = 10; +const HISTORY_CACHE_MAX_ITEMS = 20; const stripDerivedImageUrls = (images: GeneratedImage[] = []) => images.map((img) => ({ @@ -46,6 +50,37 @@ const stripDerivedTaskUrls = (task: HistoryItem): HistoryItem => ({ images: stripDerivedImageUrls(task.images || []), }); +const getBoundedHistoryCacheItems = (items: HistoryItem[] = []) => + items.slice(0, HISTORY_CACHE_MAX_ITEMS).map(stripDerivedTaskUrls); + +const getCachePageFromItems = (items: HistoryItem[]) => Math.max(1, Math.ceil(items.length / HISTORY_PAGE_SIZE)); + +const normalizePersistedHistoryState = ( + persistedState: unknown, + fallbackState: PersistedHistoryState +): PersistedHistoryState => { + const incoming = persistedState as Partial | undefined; + if (!incoming || typeof incoming !== 'object') { + return fallbackState; + } + + const items = getBoundedHistoryCacheItems(Array.isArray(incoming.items) ? incoming.items : fallbackState.items); + const total = typeof incoming.total === 'number' ? incoming.total : fallbackState.total; + const pageFromItems = getCachePageFromItems(items); + const incomingPage = typeof incoming.page === 'number' && incoming.page > 0 ? incoming.page : fallbackState.page; + const page = Math.min(incomingPage, pageFromItems); + const lastLoadedAt = typeof incoming.lastLoadedAt === 'number' ? incoming.lastLoadedAt : fallbackState.lastLoadedAt; + const hasMore = typeof incoming.hasMore === 'boolean' ? items.length < total || incoming.hasMore : items.length < total; + + return { + items, + total, + page, + hasMore, + lastLoadedAt + }; +}; + const mergeImages = (existing: GeneratedImage[] = [], incoming: GeneratedImage[] = []) => { if (!incoming.length) return existing; const imageMap = new Map(existing.map((img) => [img.id, img])); @@ -140,12 +175,12 @@ export const useHistoryStore = create()( const response = searchKeyword ? await searchHistory({ page: currentPage, - pageSize: 10, + pageSize: HISTORY_PAGE_SIZE, keyword: searchKeyword }) : await getHistory({ page: currentPage, - pageSize: 10 + pageSize: HISTORY_PAGE_SIZE }); // 如果已经有更新的请求在进行/完成,忽略当前结果 @@ -212,7 +247,7 @@ export const useHistoryStore = create()( reconcileHistoryPage: async () => { const { searchKeyword, page } = get(); - const pageSize = 10; + const pageSize = HISTORY_PAGE_SIZE; try { const response = searchKeyword @@ -401,30 +436,20 @@ export const useHistoryStore = create()( { name: 'history-cache', storage: createJSONStorage(() => localStorage), - version: 1, - partialize: (state) => ({ - items: state.items, - total: state.total, - page: state.page, - lastLoadedAt: state.lastLoadedAt + version: 2, + partialize: (state) => normalizePersistedHistoryState(state, state), + migrate: (persistedState) => normalizePersistedHistoryState(persistedState, { + items: [], + hasMore: true, + page: 1, + total: 0, + lastLoadedAt: null }), merge: (persistedState, currentState) => { - const incoming = persistedState as Partial | undefined; - if (!incoming || typeof incoming !== 'object') { - return currentState; - } - const items = Array.isArray(incoming.items) ? incoming.items : currentState.items; - const total = typeof incoming.total === 'number' ? incoming.total : currentState.total; - const page = typeof incoming.page === 'number' ? incoming.page : currentState.page; - const lastLoadedAt = - typeof incoming.lastLoadedAt === 'number' ? incoming.lastLoadedAt : currentState.lastLoadedAt; - return { - ...currentState, - items: items.map(stripDerivedTaskUrls), - total, - page, - lastLoadedAt, - hasMore: items.length < total, + const normalized = normalizePersistedHistoryState(persistedState, currentState); + return { + ...currentState, + ...normalized, loading: false }; } diff --git a/desktop/src/vite-env.d.ts b/desktop/src/vite-env.d.ts index 9a47d0c..c6be65c 100644 --- a/desktop/src/vite-env.d.ts +++ b/desktop/src/vite-env.d.ts @@ -1,5 +1,6 @@ /// interface Window { - __TAURI_INTERNALS__?: any; + __TAURI_INTERNALS__?: unknown; + __BANANA_DRAG_IMAGE_DATA__?: unknown; } diff --git a/desktop/vite.config.ts b/desktop/vite.config.ts index a146727..852b85d 100644 --- a/desktop/vite.config.ts +++ b/desktop/vite.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ manualChunks: { // React 核心 'vendor-react': ['react', 'react-dom'], - // 状态管理和数据请求 - 'vendor-data': ['zustand', '@tanstack/react-query'], + // 状态管理 + 'vendor-data': ['zustand'], // 国际化 'vendor-i18n': ['i18next', 'react-i18next'], // 工具库 diff --git a/docker-compose.yml b/docker-compose.yml index b3c5966..24b3209 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,7 +32,7 @@ services: - TZ=Asia/Shanghai restart: unless-stopped healthcheck: - test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/api/v1/health"] + test: ["CMD-SHELL", "wget -q -T 3 -t 1 --spider http://localhost/ && wget -q -T 3 -t 1 --spider http://localhost/api/v1/health"] interval: 30s timeout: 10s retries: 3 diff --git a/docker/nginx.conf b/docker/nginx.conf index 1b1bdb6..09d0ace 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -17,6 +17,11 @@ http { access_log /var/log/nginx/access.log main; + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + sendfile on; tcp_nopush on; tcp_nodelay on; @@ -59,7 +64,7 @@ http { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; + proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -67,7 +72,6 @@ http { proxy_cache_bypass $http_upgrade; # WebSocket 支持 - proxy_set_header Connection ""; proxy_connect_timeout 60s; proxy_send_timeout 300s; proxy_read_timeout 300s; diff --git a/docs/superpowers/plans/2026-04-28-p0-p2-optimization-plan.md b/docs/superpowers/plans/2026-04-28-p0-p2-optimization-plan.md new file mode 100644 index 0000000..2544d80 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-p0-p2-optimization-plan.md @@ -0,0 +1,390 @@ +# P0-P2 Optimization Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement all P0-P2 reliability, performance, deployment, and maintainability improvements one at a time, with docs updates and an atomic commit after each item. + +**Architecture:** The work preserves the existing React desktop frontend, Tauri shell, Go sidecar backend, and Docker Web architecture. Each item is independently verifiable and committed with its matching `CLAUDE.md` and `README.md` updates before moving to the next item. + +**Tech Stack:** Go/Gin/GORM/SQLite, React 18/TypeScript/Zustand/Vite/Tauri, Docker/Nginx, GitHub Actions. + +--- + +## Global Rules + +- Do not combine multiple optimization IDs in one commit. +- Every task must update `CLAUDE.md` and `README.md` in the same commit as the code/config change. +- Every git command must use `GIT_MASTER=1`. +- Commit style: English semantic commits. +- If a verification command fails, fix the root cause before committing. +- If a task expands beyond the listed files, stop and document why before adding scope. + +## File Responsibility Map + +- `.github/workflows/pr-check.yml`: PR verification, backend smoke test, blocking checks. +- `.github/workflows/release.yml`: release build/test pipeline. +- `backend/internal/api/multipart_helper.go`: multipart reference image parsing and size limits. +- `backend/internal/api/handlers.go`: local reference image path loading and API task handling. +- `backend/internal/provider/*.go`: provider HTTP response logging and request behavior. +- `backend/cmd/server/main.go`: HTTP server configuration and worker pool initialization. +- `backend/internal/worker/pool.go`: worker timeout and provider execution lifecycle. +- `desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx`: template market shell and grid rendering. +- `desktop/src/services/api.ts`: frontend API helper and image URL diagnostics. +- `desktop/src/store/historyStore.ts`: persisted history cache behavior. +- `desktop/src/components/**`: Zustand subscription narrowing and large component splits. +- `docker/nginx.conf`, `Dockerfile`, `docker-compose.yml`: Web deployment proxy and health behavior. +- `frontend/package.json`: standalone Web frontend version/build strategy. +- `desktop/src/i18n/index.ts`: locale loading strategy. +- `desktop/src-tauri/tauri.conf.json`: desktop security boundaries. +- `CLAUDE.md`: AI/developer project constraints. +- `README.md`: user/developer-facing behavior and setup documentation. + +## Reusable Per-Task Commit Checklist + +For every task below: + +- [ ] Inspect the listed files and confirm current behavior. +- [ ] Make the smallest code/config change that satisfies the task. +- [ ] Update `CLAUDE.md` with the new constraint or verification note. +- [ ] Update `README.md` with user/developer-facing behavior. +- [ ] Run the task-specific verification commands. +- [ ] Run `GIT_MASTER=1 git status --short` and review changed files. +- [ ] Stage only files for this task. +- [ ] Commit with the task's commit message, Sisyphus footer, and co-author trailer. + +Commit body template: + +```bash +GIT_MASTER=1 git commit -m "" \ + -m "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)" \ + -m "Co-authored-by: Sisyphus " +``` + +--- + +### Task 1: P1-01 Align Go workflow version + +**Files:** +- Modify: `.github/workflows/pr-check.yml` +- Modify: `.github/workflows/release.yml` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Replace hard-coded Go `1.21` workflow setup with `go-version-file: ./backend/go.mod` or explicit current `1.24.3` consistently. +- [ ] Ensure Go cache still points at `./backend/go.sum`. +- [ ] Document that CI Go version follows `backend/go.mod`. +- [ ] Run: `cd backend && go test ./... && go vet ./...` +- [ ] Commit: `ci: align go workflow version` + +### Task 2: P1-02 Fix PR smoke health check + +**Files:** +- Modify: `.github/workflows/pr-check.yml` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Change smoke test curl path to `http://localhost:8080/api/v1/health`. +- [ ] Remove `continue-on-error: true` from smoke test so failure blocks PR. +- [ ] Ensure PR summary depends on smoke-test if it reports smoke status. +- [ ] Document `/api/v1/health` as the canonical backend health endpoint. +- [ ] Run equivalent local check: start backend, then `curl -sf http://localhost:8080/api/v1/health`. +- [ ] Commit: `ci: fix backend smoke health check` + +### Task 3: P1-03 Use npm ci for release builds + +**Files:** +- Modify: `.github/workflows/release.yml` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Replace release build `npm install` calls with `npm ci` where lockfiles exist. +- [ ] Keep any platform-specific setup unchanged. +- [ ] Document that release artifacts must respect lockfiles. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `ci: use npm ci for release builds` + +### Task 4: P0-01 Limit reference image upload size + +**Files:** +- Modify: `backend/internal/api/multipart_helper.go` +- Modify: `backend/internal/api/handlers.go` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Add constants for max reference image count, single image size, and total image bytes. +- [ ] Enforce limits in streaming multipart parser before appending bytes. +- [ ] Enforce equivalent limits in standard library fallback parser. +- [ ] Check local `refPaths` with `os.Stat` before `os.ReadFile`. +- [ ] Return clear errors for count/size violations. +- [ ] Document limits and error behavior. +- [ ] Run: `cd backend && go test ./... && go vet ./...` +- [ ] Commit: `fix: limit reference image upload size` + +### Task 5: P0-02 Summarize provider response logs + +**Files:** +- Modify: `backend/internal/provider/openai.go` +- Modify: `backend/internal/provider/gemini.go` +- Modify: `backend/internal/provider/openai_image.go` +- Modify: `backend/internal/diagnostic/*` if a shared helper is needed +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Add or reuse a helper that logs response length and safe preview only. +- [ ] Replace full response body diagnostic logs in OpenAI, Gemini, and OpenAI Image providers. +- [ ] Preserve status, elapsed time, request id, and response size. +- [ ] Keep full error previews bounded. +- [ ] Document that provider diagnostics are redacted and length-limited by default. +- [ ] Run: `cd backend && go test ./... && go vet ./...` +- [ ] Commit: `fix: summarize provider response logs` + +### Task 6: P1-04 Add backend server timeouts + +**Files:** +- Modify: `backend/cmd/server/main.go` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Configure `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, and `IdleTimeout` on `http.Server`. +- [ ] Ensure timeout values do not break long-running generation status/SSE behavior. +- [ ] Document server connection timeout strategy. +- [ ] Run: `cd backend && go test ./... && go vet ./...` +- [ ] Start backend and verify: `curl -sf http://localhost:8080/api/v1/health` +- [ ] Commit: `fix: add backend server timeouts` + +### Task 7: P2-01 Tighten worker timeout handling + +**Files:** +- Modify: `backend/internal/worker/pool.go` +- Modify: provider call comments or interfaces only if necessary +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Review current provider goroutine pattern and context cancellation behavior. +- [ ] Prefer executing `p.Generate(ctx, task.Params)` directly in the worker goroutine if panic recovery can be preserved. +- [ ] If direct execution is unsafe, add explicit leak-risk guardrails and diagnostics. +- [ ] Preserve task failure semantics for deadline exceeded. +- [ ] Document provider context requirements. +- [ ] Run: `cd backend && go test ./... && go vet ./...` +- [ ] Commit: `fix: tighten worker timeout handling` + +### Task 8: P0-03 Virtualize template market grid + +**Files:** +- Modify: `desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx` +- Create/Modify: focused template grid component if needed under `desktop/src/components/TemplateMarket/` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Reuse `react-window` grid patterns from `HistoryPanel/HistoryList.tsx`. +- [ ] Render only visible template cards while preserving responsive columns. +- [ ] Preserve filtering, empty state, preview modal, and apply behavior. +- [ ] Document template market virtualization. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `perf: virtualize template market grid` + +### Task 9: P1-05 Gate image URL diagnostics + +**Files:** +- Modify: `desktop/src/services/api.ts` +- Modify: diagnostic/config helper if needed +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Find all hot-path `getImageUrl` console logs. +- [ ] Guard logs behind an existing verbose/diagnostic flag or add a minimal local helper. +- [ ] Keep actionable logs available when diagnostics are enabled. +- [ ] Document that image URL diagnostics are off by default. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `perf: gate image url diagnostics` + +### Task 10: P1-06 Narrow Zustand subscriptions + +**Files:** +- Modify: `desktop/src/components/Settings/SettingsModal.tsx` +- Modify: `desktop/src/components/ConfigPanel/BatchSettings.tsx` if present +- Modify: `desktop/src/components/GenerateArea/BatchActions.tsx` if present +- Modify: `desktop/src/components/Toast.tsx` or actual toast component path +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Replace whole-store subscriptions with selectors. +- [ ] Use shallow comparison where multiple fields are selected. +- [ ] Avoid behavior changes while reducing re-render triggers. +- [ ] Document Zustand selector convention. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `perf: narrow zustand subscriptions` + +### Task 11: P1-07 Slim persisted history cache + +**Files:** +- Modify: `desktop/src/store/historyStore.ts` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Reduce `partialize` to lightweight fields or recent bounded items only. +- [ ] Ensure persisted data merge strips derived URLs and handles old cache safely. +- [ ] Preserve history load-more and current task sync behavior. +- [ ] Document history cache persistence limits. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `perf: slim persisted history cache` + +### Task 12: P2-02 Align Web frontend version strategy + +**Files:** +- Modify: `frontend/package.json` +- Modify: Docker or docs only if version strategy requires it +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Decide from repo evidence whether standalone Web frontend should remain independent or align to desktop version. +- [ ] If aligning, update version metadata and docs consistently. +- [ ] If independent, add explicit docs explaining the split and prevent accidental confusion. +- [ ] Run: `cd frontend && npm run type-check && npm run build` +- [ ] Run: `docker compose config` +- [ ] Commit: `chore: align web frontend version strategy` + +### Task 13: P2-03 Correct Nginx upgrade headers + +**Files:** +- Modify: `docker/nginx.conf` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Replace duplicate `Connection` header configuration with a standard upgrade-safe pattern. +- [ ] Avoid breaking normal API proxy requests. +- [ ] Document long-connection proxy behavior. +- [ ] Run: `docker compose config` +- [ ] Commit: `fix: correct nginx upgrade headers` + +### Task 14: P2-04 Improve Docker health checks + +**Files:** +- Modify: `Dockerfile` +- Modify: `docker-compose.yml` +- Modify: `docker/nginx.conf` if needed +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Ensure health checks cover backend API and Nginx/frontend availability. +- [ ] Keep checks fast and non-flaky. +- [ ] Document Docker health coverage. +- [ ] Run: `docker compose config` +- [ ] If practical: `docker compose build` +- [ ] Commit: `fix: improve docker health checks` + +### Task 15: P2-05 Consolidate provider config API + +**Files:** +- Modify: `desktop/src/services/configApi.ts` +- Modify: `desktop/src/services/providerApi.ts` +- Modify: affected imports/callers +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Pick one authoritative ProviderConfig type and update method location. +- [ ] Remove duplicate definitions or re-export from one source. +- [ ] Update all imports to the canonical source. +- [ ] Document provider API ownership. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `refactor: consolidate provider config api` + +### Task 16: P2-06A Split template market components + +**Files:** +- Modify: `desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx` +- Create: one focused component or hook under `desktop/src/components/TemplateMarket/` +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Extract one clear responsibility, such as filter bar, grid shell, or card list wrapper. +- [ ] Keep behavior and styling identical. +- [ ] Do not rewrite the full file. +- [ ] Document template market component boundary. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `refactor: split template market components` + +### Task 17: P2-06B Split reference image upload logic + +**Files:** +- Modify: `desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx` +- Create: one focused hook or component near the existing file +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Extract one clear responsibility: drag/drop, paste handling, compression, or persistence. +- [ ] Preserve add/delete/drag/paste behavior. +- [ ] Do not rewrite the full file. +- [ ] Document reference upload logic boundary. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `refactor: split reference image upload logic` + +### Task 18: P2-06C Split settings modal sections + +**Files:** +- Modify: `desktop/src/components/Settings/SettingsModal.tsx` +- Create: one focused provider form/field-group component or hook +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Extract one settings section without changing save/test/switch behavior. +- [ ] Keep props explicit and typed. +- [ ] Do not rewrite the full file. +- [ ] Document settings modal section boundary. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `refactor: split settings modal sections` + +### Task 19: P2-07 Clarify React Query usage + +**Files:** +- Modify: `desktop/src/App.tsx` +- Modify: `desktop/package.json` and lockfile if removing dependency +- Modify: service layer only if migrating one small call +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Search for actual `useQuery`/`useMutation` usage. +- [ ] If unused, remove provider and dependency cleanly. +- [ ] If intentionally retained, document the migration strategy and keep runtime behavior unchanged. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `chore: clarify react query usage` + +### Task 20: P2-08 Lazy load desktop locales + +**Files:** +- Modify: `desktop/src/i18n/index.ts` +- Modify: language switching logic if needed +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Load default language at startup. +- [ ] Dynamically import non-default locale JSON when language changes. +- [ ] Preserve zh-CN, en-US, ja-JP, ko-KR availability. +- [ ] Document locale loading strategy. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] Commit: `perf: lazy load desktop locales` + +### Task 21: P2-09 Tighten Tauri asset security + +**Files:** +- Modify: `desktop/src-tauri/tauri.conf.json` +- Modify: related asset path code only if needed +- Modify: `CLAUDE.md` +- Modify: `README.md` + +- [ ] Narrow asset protocol scope away from broad `$HOME/**` where possible. +- [ ] Add a minimal CSP that still permits required asset/blob/http(s) image sources. +- [ ] Verify local image loading expectations from existing asset protocol docs. +- [ ] Document Tauri asset/CSP constraints. +- [ ] Run: `cd desktop && npm run type-check && npm run build` +- [ ] If practical: `cd desktop && npm run tauri build` +- [ ] Commit: `fix: tighten tauri asset security` + +## Final Verification Task + +- [ ] Run backend verification: `cd backend && go test ./... && go vet ./...` +- [ ] Run desktop verification: `cd desktop && npm run type-check && npm run build` +- [ ] Run Web verification: `cd frontend && npm run type-check && npm run build` +- [ ] Run Docker config verification: `docker compose config` +- [ ] Run `GIT_MASTER=1 git log --oneline -25` and confirm each optimization has its own commit. +- [ ] Report completed commits and any skipped expensive verification with reasons. diff --git a/docs/superpowers/specs/2026-04-28-p0-p2-optimization-design.md b/docs/superpowers/specs/2026-04-28-p0-p2-optimization-design.md new file mode 100644 index 0000000..a3474ba --- /dev/null +++ b/docs/superpowers/specs/2026-04-28-p0-p2-optimization-design.md @@ -0,0 +1,254 @@ +# P0-P2 优化执行设计 + +## 背景 + +当前项目是 React 桌面前端、Tauri 容器、Go Sidecar 后端以及 Docker Web 版并存的三层架构。代码体检发现的 P0-P2 问题横跨前端渲染、后端内存与日志、CI 发布可靠性、Docker/Nginx 配置和文档维护。 + +用户要求:P0-P2 全部逐一处理;每个优化项完成后都要更新 `CLAUDE.md` 与 `README.md`,并单独提交 commit。 + +## 目标 + +1. 用小步、可验证、可回滚的方式处理所有 P0-P2 优化项。 +2. 每个提交只解决一个清晰问题,避免跨模块大杂烩提交。 +3. 每项改动都同步项目说明,保证后续 AI/开发者能理解新的约束与验证方式。 +4. 优先处理低风险、高收益、对后续工作有铺垫作用的项目。 + +## 非目标 + +1. 不一次性重构整个前端或后端。 +2. 不引入大型新框架。 +3. 不改变图片生成核心业务行为,除非该行为本身存在稳定性风险。 +4. 不把所有 P0-P2 混成一个大 commit。 + +## 执行顺序 + +### 第一阶段:CI 与发布可靠性 + +#### P1-01:统一 Go CI 版本 + +- 改动范围:`.github/workflows/pr-check.yml`、`.github/workflows/release.yml`、`CLAUDE.md`、`README.md`。 +- 验收标准:CI 中 Go 版本来源与 `backend/go.mod` 一致,不再固定为旧版本。 +- 必跑验证:人工核对 GitHub Actions YAML;本地执行 `cd backend && go test ./... && go vet ./...`。 +- 文档更新点:说明 CI Go 版本应跟随 `backend/go.mod`。 +- commit 示例:`ci: align go workflow version` + +#### P1-02:修正 PR smoke test 健康检查 + +- 改动范围:`.github/workflows/pr-check.yml`、`CLAUDE.md`、`README.md`。 +- 验收标准:smoke test 检查 `/api/v1/health`,失败会阻断 PR。 +- 必跑验证:人工核对 GitHub Actions YAML;本地启动后端时用 `curl -sf http://localhost:8080/api/v1/health` 验证。 +- 文档更新点:记录标准健康检查路径。 +- commit 示例:`ci: fix backend smoke health check` + +#### P1-03:发布流程使用可复现安装 + +- 改动范围:`.github/workflows/release.yml`、`CLAUDE.md`、`README.md`。 +- 验收标准:发布构建阶段使用 `npm ci`,不使用 `npm install` 生成发布产物。 +- 必跑验证:人工核对 GitHub Actions YAML;本地执行 `cd desktop && npm ci --prefer-offline` 如依赖环境允许。 +- 文档更新点:说明发布构建必须尊重 lockfile。 +- commit 示例:`ci: use npm ci for release builds` + +理由:这些改动风险低,可以先提高后续每个优化项的验证可信度。 + +### 第二阶段:后端稳定性与资源边界 + +#### P0-01:限制参考图上传和本地读取大小 + +- 改动范围:`backend/internal/api/multipart_helper.go`、相关本地参考图读取逻辑、`CLAUDE.md`、`README.md`。 +- 验收标准:单图大小、总大小、参考图数量都有明确限制;超限返回清晰错误。 +- 必跑验证:`cd backend && go test ./... && go vet ./...`;必要时用超大文件做手动验证。 +- 文档更新点:说明参考图上传限制和错误行为。 +- commit 示例:`fix: limit reference image upload size` + +#### P0-02:Provider 响应日志摘要化 + +- 改动范围:`backend/internal/provider/*.go`、诊断日志辅助逻辑、`CLAUDE.md`、`README.md`。 +- 验收标准:默认日志不写完整 base64 响应体;保留状态码、耗时、request id、body 长度和安全摘要。 +- 必跑验证:`cd backend && go test ./... && go vet ./...`。 +- 文档更新点:说明诊断日志默认脱敏和限长。 +- commit 示例:`fix: summarize provider response logs` + +#### P1-04:HTTP Server 增加基础超时 + +- 改动范围:`backend/cmd/server/main.go`、`CLAUDE.md`、`README.md`。 +- 验收标准:Server 配置 `ReadHeaderTimeout`、`ReadTimeout`、`WriteTimeout`、`IdleTimeout`;SSE 不被误伤。 +- 必跑验证:`cd backend && go test ./... && go vet ./...`;本地启动后端并验证 `/api/v1/health`。 +- 文档更新点:说明服务端连接超时策略。 +- commit 示例:`fix: add backend server timeouts` + +#### P2-01:降低 Worker 超时后资源占用风险 + +- 改动范围:`backend/internal/worker/pool.go`、必要的 Provider 调用约束、`CLAUDE.md`、`README.md`。 +- 验收标准:超时后不会无限积累后台 Provider goroutine;失败状态保持准确。 +- 必跑验证:`cd backend && go test ./... && go vet ./...`。 +- 文档更新点:说明 Worker 超时与 Provider context 约束。 +- commit 示例:`fix: tighten worker timeout handling` + +理由:这些项直接影响内存、磁盘日志、异常请求防护和长时间运行稳定性。 + +### 第三阶段:前端性能 + +#### P0-03:模板市场虚拟化 + +- 改动范围:`desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx` 及必要子组件、`CLAUDE.md`、`README.md`。 +- 验收标准:模板列表不再一次性渲染全部模板;搜索、筛选、预览、应用模板保持可用。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明模板市场使用虚拟化以支撑大规模模板。 +- commit 示例:`perf: virtualize template market grid` + +#### P1-05:关闭图片 URL 热路径默认日志 + +- 改动范围:`desktop/src/services/api.ts`、诊断开关相关逻辑、`CLAUDE.md`、`README.md`。 +- 验收标准:默认批量图片渲染不刷控制台;开启诊断时仍可排查图片 URL。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明图片 URL 日志只在诊断模式输出。 +- commit 示例:`perf: gate image url diagnostics` + +#### P1-06:收敛 Zustand 宽订阅 + +- 改动范围:宽订阅组件,例如 `SettingsModal`、`BatchSettings`、`BatchActions`、`Toast`,以及 `CLAUDE.md`、`README.md`。 +- 验收标准:组件只订阅实际使用字段;不改变 UI 行为。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明 Zustand 新代码应使用 selector 和浅比较。 +- commit 示例:`perf: narrow zustand subscriptions` + +#### P1-07:历史缓存瘦身 + +- 改动范围:`desktop/src/store/historyStore.ts`、相关缓存迁移逻辑、`CLAUDE.md`、`README.md`。 +- 验收标准:localStorage 不再持久化过重派生数据;历史列表加载和同步行为保持正确。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明历史缓存只保存轻量字段。 +- commit 示例:`perf: slim persisted history cache` + +理由:这些项收益明显,但前端 UI 改动需要更谨慎验证,所以放在 CI/后端边界之后。 + +### 第四阶段:Docker/Web 部署 + +#### P2-02:处理 Docker Web 版版本滞后 + +- 改动范围:`frontend/package.json`、Docker 构建入口或版本同步检查、`CLAUDE.md`、`README.md`。 +- 验收标准:明确 Web 版是继续独立还是跟随桌面版;如果跟随,版本和关键说明同步。 +- 必跑验证:`cd frontend && npm run type-check && npm run build`;必要时 `docker compose config`。 +- 文档更新点:说明 Web 版与桌面版的同步策略。 +- commit 示例:`chore: align web frontend version strategy` + +#### P2-03:修复 Nginx Upgrade 头配置 + +- 改动范围:`docker/nginx.conf`、`CLAUDE.md`、`README.md`。 +- 验收标准:`Connection` 头不被重复覆盖;长连接代理配置清晰。 +- 必跑验证:`docker compose config`;必要时构建 Docker 镜像。 +- 文档更新点:说明 Nginx 长连接代理配置。 +- commit 示例:`fix: correct nginx upgrade headers` + +#### P2-04:增强 Docker 健康检查 + +- 改动范围:`Dockerfile`、`docker-compose.yml`、`docker/nginx.conf`、`CLAUDE.md`、`README.md`。 +- 验收标准:健康检查能覆盖后端 API 和前端/Nginx 静态服务,不只检查单一路径。 +- 必跑验证:`docker compose config`;必要时 `docker compose build`。 +- 文档更新点:说明 Docker 健康检查覆盖范围。 +- commit 示例:`fix: improve docker health checks` + +理由:这些项影响 Web 版部署体验,需要结合当前 Web/桌面分支策略小步推进。 + +### 第五阶段:可维护性整理 + +#### P2-05:合并重复 Provider/config API + +- 改动范围:`desktop/src/services/configApi.ts`、`desktop/src/services/providerApi.ts`、相关调用方、`CLAUDE.md`、`README.md`。 +- 验收标准:Provider 配置类型和更新方法只有一个权威来源;调用方通过统一入口使用。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明 Provider API 类型维护入口。 +- commit 示例:`refactor: consolidate provider config api` + +#### P2-06A:拆分模板市场超大组件 + +- 改动范围:`desktop/src/components/TemplateMarket/TemplateMarketDrawer.tsx`、新增的模板市场子组件或 hooks、`CLAUDE.md`、`README.md`。 +- 验收标准:至少抽出一个明确职责的模板市场子组件或 hook;搜索、筛选、预览、应用模板行为不变;不做整文件重写。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明模板市场组件拆分边界。 +- commit 示例:`refactor: split template market components` + +#### P2-06B:拆分参考图上传超大组件 + +- 改动范围:`desktop/src/components/ConfigPanel/ReferenceImageUpload.tsx`、新增的参考图上传子组件或 hooks、`CLAUDE.md`、`README.md`。 +- 验收标准:至少抽出一个明确职责的上传、拖拽、压缩或持久化 hook/子组件;参考图添加、删除、拖拽、粘贴行为不变;不做整文件重写。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明参考图上传逻辑拆分边界。 +- commit 示例:`refactor: split reference image upload logic` + +#### P2-06C:拆分设置弹窗超大组件 + +- 改动范围:`desktop/src/components/Settings/SettingsModal.tsx`、新增的设置表单子组件或 hooks、`CLAUDE.md`、`README.md`。 +- 验收标准:至少抽出一个明确职责的 provider 设置表单、通用字段组或设置 hook;保存、测试连接、切换 provider 行为不变;不做整文件重写。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明设置弹窗拆分边界。 +- commit 示例:`refactor: split settings modal sections` + +#### P2-07:清理或明确 React Query 策略 + +- 改动范围:`desktop/src/App.tsx`、`desktop/package.json`、可能的服务层调用、`CLAUDE.md`、`README.md`。 +- 验收标准:如果不用 React Query,则移除无效 Provider/依赖;如果保留,则明确后续迁移入口。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明数据请求策略。 +- commit 示例:`chore: clarify react query usage` + +#### P2-08:i18n 语言包延迟加载 + +- 改动范围:`desktop/src/i18n/index.ts`、语言切换逻辑、`CLAUDE.md`、`README.md`。 +- 验收标准:默认语言可正常启动,其它语言切换时加载;四种语言仍可用。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`。 +- 文档更新点:说明语言包加载策略。 +- commit 示例:`perf: lazy load desktop locales` + +#### P2-09:收敛 Tauri asset scope 与 CSP + +- 改动范围:`desktop/src-tauri/tauri.conf.json`、必要的资源访问说明、`CLAUDE.md`、`README.md`。 +- 验收标准:asset scope 尽量限制到应用数据/存储目录;CSP 不再完全关闭,且图片加载仍可用。 +- 必跑验证:`cd desktop && npm run type-check && npm run build`;必要时 `npm run tauri build`。 +- 文档更新点:说明 Tauri 安全边界。 +- commit 示例:`fix: tighten tauri asset security` + +理由:这些项更偏长期维护,收益稳定但需要避免牵连过广。 + +## 每项交付标准 + +每个优化项都必须满足以下条件后才能 commit: + +1. 代码改动完成。 +2. `CLAUDE.md` 更新对应开发约束、验证命令或注意事项。 +3. `README.md` 更新用户/开发者可见说明。 +4. 运行该优化项列出的必跑验证命令。 +5. 验证通过后立即单独 commit,再进入下一项;禁止连续完成多项后批量提交。 +6. 提交信息使用项目既有语义化风格,例如 `fix: align ci go version`。 + +## 风险与回滚 + +1. CI 版本变更可能暴露旧代码对新 Go toolchain 的不兼容;回滚方式是恢复原 workflow 版本并单独排查后端兼容性。 +2. 健康检查失败阻断 PR 后,已有不稳定启动逻辑会更早暴露;回滚方式是临时恢复容错,但必须记录原因。 +3. 上传大小限制可能影响用户超大参考图;需要给出清晰错误信息,而不是静默失败。 +4. 日志摘要化不能丢失排障关键信息;需要保留 request id、状态码、耗时和响应长度。 +5. Worker 超时调整可能改变失败时序;回滚方式是恢复原 goroutine 模式并补充监控。 +6. 前端虚拟化可能影响模板市场滚动和响应式布局;需要重点验证搜索、筛选、预览、应用模板。 +7. Nginx 头配置调整可能影响 SSE/WebSocket 兼容;回滚方式是恢复旧配置并保留失败案例。 +8. i18n 延迟加载可能影响语言切换首屏;回滚方式是恢复静态导入。 +9. Tauri CSP/asset scope 收敛可能阻断合法图片加载;回滚方式是扩大到上一个可用 scope,并记录缺失路径。 +10. Docker Web 版本同步可能涉及 `frontend/` 与 `desktop/` 分叉策略;如范围扩大,应拆成独立提交处理。 + +每个 commit 都应能独立 revert,且不影响其他已完成优化项。 + +## 验证路线 + +1. 第一阶段完成后,确认 GitHub Actions 配置逻辑正确,本地命令能通过。 +2. 第二阶段完成后,用后端测试与基础 API 启动检查验证。 +3. 第三阶段完成后,用桌面前端 type-check/build 验证,并手动说明需重点回归的 UI 流程。 +4. 第四阶段完成后,用 Docker 构建或配置检查验证。 +5. 第五阶段完成后,做一次完整前后端验证。 + +## 提交策略 + +采用逐项提交: + +- 每个优化项一个 commit。 +- 文档与对应代码同 commit。 +- 不把多个无依赖优化合并提交。 +- 如果某项必须跨多个模块,先说明原因,并保持文件数量尽量少。 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f73ec1c..60fb4b1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "nano-banana-pro-frontend", - "version": "1.0.0", + "version": "2.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nano-banana-pro-frontend", - "version": "1.0.0", + "version": "2.8.4", "dependencies": { "@tanstack/react-query": "^5.59.20", "axios": "^1.7.7", diff --git a/frontend/package.json b/frontend/package.json index c6e56ed..9b8f12d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "nano-banana-pro-frontend", "private": true, - "version": "2.5.2", + "version": "2.8.4", "description": "大香蕉图片生成工具 - 批量图片生成应用前端", "type": "module", "scripts": {