diff --git a/.github/workflows/publish-images.yml b/.github/workflows/publish-images.yml index fae278173..1be8c0c82 100644 --- a/.github/workflows/publish-images.yml +++ b/.github/workflows/publish-images.yml @@ -48,9 +48,12 @@ jobs: echo "build_time=$(git show -s --format=%cI HEAD)" >> "$GITHUB_OUTPUT" echo "owner=${REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" + # 镜像构建与质量检查并行:构建只推送不可变的 sha- 标签,不会被部署使用; + # 可变标签(latest / 版本号)的推广在 publish 中进行,并且必须等待 checks 通过。 + # 这样发布总耗时约为 max(检查, 构建) 而不是两者相加。 build: name: Build ${{ matrix.image }} (${{ matrix.arch }}) - needs: [checks, prepare] + needs: [prepare] permissions: contents: read packages: write @@ -135,7 +138,7 @@ jobs: publish: name: Publish ${{ matrix.image }} manifest - needs: [prepare, build] + needs: [checks, prepare, build] runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index dc6671660..60d991e58 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -7,10 +7,18 @@ on: permissions: contents: read +# 同一个 PR 连续推送时取消旧的检查,只保留最新提交;被发布流程调用(workflow_call)时 +# 每次运行使用独立分组,绝不互相取消,避免中间提交的镜像发布被跳过。 +concurrency: + group: quality-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: backend: name: Backend checks runs-on: ubuntu-latest + # go test 自身有 20 分钟超时;这里兜底防止依赖服务卡死时占用 Runner 数小时。 + timeout-minutes: 30 defaults: run: working-directory: backend @@ -59,8 +67,9 @@ jobs: shell: bash run: | set -o pipefail - # The app package takes roughly nine minutes on a local full run; - # leave margin for slower runners and dependency-backed integration tests. + # internal/app 约 1000 个用例串行执行,是后端检查的关键路径(本地约 2–3 分钟)。 + # 此前每个用例构造 Service 时都会完整解压 85MB 官方插件包(约 4 秒/次,累计 7 分钟以上), + # 现已改为只读清单 + 进程内缓存;若该包耗时再次明显上涨,先查 newPluginRuntime。 go test -json -timeout=20m ./... | tee "$RUNNER_TEMP/go-tests.jsonl" - name: Ensure dependency-backed tests were not skipped shell: bash @@ -104,6 +113,7 @@ jobs: web: name: Web checks runs-on: ubuntu-latest + timeout-minutes: 20 defaults: run: working-directory: web diff --git a/PI_AGENT_FINAL_ANALYSIS.md b/PI_AGENT_FINAL_ANALYSIS.md new file mode 100644 index 000000000..b2e12bf90 --- /dev/null +++ b/PI_AGENT_FINAL_ANALYSIS.md @@ -0,0 +1,504 @@ +# Pi Agent 生产问题完整分析与最优解决方案 + +## 问题清单(根据对话历史重构) + +### 已完成修复 + +#### 1. ✅ stdout/Wait 竞态(生产诊断号 ag3a1b9dedb21d5903bdfcc304c59da938) +**问题**:正常结束的 Agent 会话报 `file already closed` + +**根因**:`cmd.Wait()` 在 stdout scanner 读完前被调用,提前关闭了 pipe + +**解决方案**: +- Scanner goroutine 读到 EOF 后再调用 `Wait()` +- 遇到 `runtime_error` 时记录但继续读取,不提前返回 +- 添加带真实 Node 的回归测试 + +**影响**:消除正常结束被误判为失败的竞态 + +--- + +#### 2. ✅ 运行时启动崩溃(本地复现诊断号 agf2bae258863606bbe7056d0ac58be6b7) +**问题**:Agent 启动后 1 秒内崩溃,报 `Cannot read properties of null (reading 'startsWith')` + +**根因**: +- 第一次修复时传了 `authPath: null`,SDK 调用 `null.startsWith()` 崩溃 +- 第二次修复用 `/dev/null`,SDK 尝试创建 `/dev/null.lock` 失败(`EPERM`) + +**最优解决方案**:每次运行创建独立临时目录 +```javascript +const isolatedDir = await mkdtemp(join(tmpdir(), "agent-runtime-")); +process.env.HOME = isolatedDir; +process.env.PI_CODING_AGENT_DIR = isolatedDir; +process.on("exit", () => rmSync(isolatedDir, { recursive: true, force: true })); + +const modelRuntime = await ModelRuntime.create({ + authPath: join(isolatedDir, "auth.json"), + modelsPath: join(isolatedDir, "models.json"), +}); +``` + +**优势**: +- 每轮完全隔离,不读取宿主 `~/.pi` +- SDK 能正常创建锁文件 +- 进程退出自动清理 + +--- + +#### 3. ✅ 安全:工具白名单 + 环境隔离 +**问题**: +- SDK 默认启用 `read/bash/edit/write` 内置工具 +- 子进程继承后端全部环境变量(DATABASE_URL、密钥) +- SDK 会读取 `~/.pi/agent/auth.json`(支持 `!command` 执行 shell) +- Resource loader 会探索文件系统 + +**最优解决方案**: + +**Node 侧**: +```javascript +// 1. 只启用平台声明的工具 +const platformToolNames = tools.map(t => t.name); +sessionConfig.tools = platformToolNames; + +// 2. 严格隔离资源加载 +const resourceLoader = new DefaultResourceLoader({ + noExtensions: true, + noSkills: true, + noPromptTemplates: true, + noThemes: true, + noContextFiles: true, + settingsManager: SettingsManager.inMemory({}), +}); +``` + +**Go 侧**: +```go +// 1. 环境变量白名单 +cmd.Env = []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + isolatedDir, + "NODE_ENV=production", + "PI_OFFLINE=1", +} + +// 2. 工具名称双重校验 +allowedTools := make(map[string]bool) +for _, tool := range state.Canonical.Tools { + if fn, ok := tool["function"].(map[string]interface{}); ok { + allowedTools[stringValue(fn["name"])] = true + } +} +if !allowedTools[call.Function.Name] { + return nil, fmt.Errorf("未声明的工具: %s", call.Function.Name) +} +``` + +**影响**: +- 关闭 SDK 内置工具 +- 子进程无法访问数据库凭证 +- 即使 Node 侧绕过,Go 侧也会拦截 + +--- + +#### 4. ✅ 媒体审批统一 +**问题**:前端说"图片、视频始终先审批",后端 auto 模式直接提交 + +**最优解决方案**: +```go +// 媒体工具在所有模式下都进入审批 +if allowed && cloudAgentWrite(call.Function.Name) && + (state.Request.PermissionMode == "request_approval" || mediaTool) && + state.Approval == nil { + // 进入审批流程 +} + +// auto 模式只豁免非媒体的画布修改 +if plan != nil && state.Request.PermissionMode == "auto" && !mediaTool { + // 直接提交 +} +``` + +更新提示词和工具说明: +- `agent-media-policy.md` 版本升到 5 +- 明确"图片、视频在所有权限模式下都先审批" +- 更新 `generate_media`、`image_layer_split` 工具描述 + +**影响**: +- 前后端行为一致 +- 避免用户扣费争议 +- auto 模式仍可快速修改文本节点和连线 + +--- + +### 暂不实现(需要更深入设计) + +#### 5. ⏸️ yingce-agent 独立容器拆分 + +**当前问题**: +- 后端没有读 `YINGCE_AGENT_URL`,仍是内嵌 Node 子进程 +- 桥接只监听 `127.0.0.1`,容器间无法通信 +- Dockerfile 复制宿主 `node_modules`,不能跨平台构建 +- 已修改 `docker-compose.deploy.yml` 要求提供镜像,会导致生产启动失败 + +**最优架构**: + +``` +┌─────────────────────┐ ┌──────────────────┐ +│ Go Backend │ │ yingce-agent │ +│ - 业务逻辑 │ ──HTTP──> │ (Node HTTP) │ +│ - 权限审批 │ <──JSON── │ - Pi runtime │ +│ - 计费扣款 │ │ - 会话管理 │ +└─────────────────────┘ └──────────────────┘ + ↓ ↓ + if YINGCE_AGENT_URL 带鉴权的网络桥接 + → HTTP client 监听 0.0.0.0:8081 + else 验证 Bridge-Token + → exec Node subprocess + 监听 127.0.0.1:随机端口 +``` + +**实现步骤**: + +**1. 网络桥接改造**: +```go +// runtime.go +func Run(ctx context.Context, request ProcessRequest, bridge Bridge) error { + // 桥接监听改为 0.0.0.0:0,支持容器间通信 + listener, err := net.Listen("tcp", "0.0.0.0:0") + + // 添加 token 验证中间件 + mux.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Bridge-Token") != token { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + }) +} +``` + +**2. HTTP 客户端**: +```go +// backend/internal/app/cloud_agent_runtime.go +func (s *Service) runCloudAgentModelStep(...) { + agentURL := os.Getenv("YINGCE_AGENT_URL") + + if agentURL != "" { + // 使用 HTTP 客户端 + client := yingceagent.NewClient(agentURL) + return client.Run(ctx, request) + } + + // 降级到内嵌模式 + return runtime.Run(ctx, request, bridge) +} +``` + +**3. yingce-agent HTTP 服务**: +```javascript +// yingce-agent/server.mjs +import express from 'express'; +import { spawn } from 'child_process'; + +app.post('/v1/agent/run', async (req, res) => { + const { bridgeURL, bridgeToken, sessionJSONL, prompt, tools, model } = req.body; + + // 验证请求来源(可选:mTLS 或共享密钥) + const authHeader = req.headers['authorization']; + if (!validateAuth(authHeader)) { + return res.status(401).json({ error: 'Unauthorized' }); + } + + // 启动 agent-runtime.mjs 子进程 + const child = spawn('node', ['agent-runtime.mjs'], { + env: { PATH: process.env.PATH, NODE_ENV: 'production', PI_OFFLINE: '1' } + }); + + child.stdin.write(JSON.stringify({ bridgeURL, bridgeToken, ... })); + child.stdin.end(); + + // 等待完成并返回结果 + const result = await collectOutput(child); + res.json(result); +}); +``` + +**4. Dockerfile 改造**: +```dockerfile +# 多阶段构建,自包含 node_modules +FROM node:22-alpine AS builder +WORKDIR /build +COPY backend/agent-runtime/pi/package*.json ./ +RUN npm ci --omit=dev +COPY backend/agent-runtime/pi/ ./ + +FROM node:22-alpine +WORKDIR /app +COPY --from=builder /build /app/agent-runtime +COPY yingce-agent/ /app/ +EXPOSE 8081 +CMD ["node", "server.mjs"] +``` + +**5. Compose 改造**: +```yaml +services: + yingce-agent: + image: ${CANVAS_YINGCE_AGENT_IMAGE} + environment: + MAX_CONCURRENT_SESSIONS: ${YINGCE_AGENT_MAX_SESSIONS:-10} + deploy: + replicas: ${YINGCE_AGENT_REPLICAS:-2} + # 不需要 depends_on,可以独立启动 + + backend: + environment: + YINGCE_AGENT_URL: ${YINGCE_AGENT_URL:-http://yingce-agent:8081} + # backend 自动降级,即使 yingce-agent 不可用也能用内嵌模式 +``` + +**优势**: +- **开发模式**:不设 `YINGCE_AGENT_URL`,自动用内嵌 Node 子进程 +- **生产模式**:设置环境变量,自动切换到 HTTP 客户端 +- **向后兼容**:即使 yingce-agent 故障,后端降级到内嵌模式 +- **弹性伸缩**:yingce-agent 可独立水平扩展 + +**为什么暂不实现**: +- 需要完整的网络鉴权设计 +- 需要健康检查和降级逻辑 +- 需要端到端测试验证容器间通信 +- 当前修复已解决核心问题,拆分不是紧急需求 + +--- + +#### 6. ⏸️ 恢复幂等性(反馈第 4 项) +**问题**:重启后同一 `idempotencyKey` 可能重复建单 + +**根因**:任务 ID 生成依赖内存状态(`state.Generations++`) + +**最优解决方案**: +```go +// 使用确定性 ID:用户 ID + 运行 ID + 调用索引 + 幂等键 +taskID := deterministicTaskID(userID, runID, state.CallIndex, call.Function.Arguments) + +func deterministicTaskID(userID, runID string, index int, args string) string { + hash := sha256.Sum256([]byte(fmt.Sprintf("%s:%s:%d:%s", userID, runID, index, args))) + return "ag" + hex.EncodeToString(hash[:])[:30] +} +``` + +**优势**: +- 重启后相同调用生成相同 ID +- 数据库主键冲突自动去重 +- 不需要额外的幂等表 + +--- + +#### 7. ⏸️ 媒体 waiter 恢复逻辑(反馈第 5 项) +**问题**:审批恢复后,旧 waiter 可能仍在轮询 + +**最优解决方案**: +```go +// 使用带取消的 context,checkpoint 里记录 waiter ID +type CloudAgentState struct { + MediaWaiterID string `json:"mediaWaiterId,omitempty"` +} + +func (s *Service) startApprovedCloudAgentMediaWaiter(ctx context.Context, userID, runID, taskID string) { + waiterID := generateID() + waiterCtx, cancel := context.WithCancel(ctx) + + s.mediaWaitersMu.Lock() + if existing, ok := s.mediaWaiters[runID]; ok { + existing.cancel() // 取消旧 waiter + } + s.mediaWaiters[runID] = &mediaWaiter{ctx: waiterCtx, cancel: cancel, id: waiterID} + s.mediaWaitersMu.Unlock() + + // 记录到 checkpoint + s.repo.MutateCloudAgent(userID, runID, ..., func(current *model.CloudAgentExecution, ...) { + state.MediaWaiterID = waiterID + }) + + // 轮询 + go func() { + defer cancel() + for { + select { + case <-waiterCtx.Done(): + return + case <-time.After(5 * time.Second): + // 检查任务状态 + } + } + }() +} +``` + +--- + +#### 8. ⏸️ 审批竞态(反馈第 6 项) +**问题**:审批决策与运行推进并发时可能冲突 + +**最优解决方案**:乐观锁 + 重试 +```go +func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision string) error { + for attempt := 0; attempt < 3; attempt++ { + run, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + + err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, ...) { + state, _ := cloudAgentDecode(current) + + // 检查审批是否仍然有效 + if state.Approval == nil || state.Approval.ID != approvalID { + return fmt.Errorf("approval already resolved") + } + + state.Approval.Decision = decision + state.Decisions[approvalID] = decision + return cloudAgentSave(current, state) + }) + + if err == nil || !errors.Is(err, repository.ErrRevisionConflict) { + return err + } + + time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) + } + + return fmt.Errorf("审批决策冲突,请重试") +} +``` + +--- + +#### 9. ⏸️ Node fetch 超时(反馈第 7 项) +**问题**:默认 300s 超时对长时间媒体生成不够 + +**最优解决方案**:分层超时 +```javascript +// agent-runtime.mjs +const bridgeTimeout = request.bridgeTimeout ?? 600000; // 默认 10 分钟 +const result = await fetch(bridgeURL, { + signal: AbortSignal.timeout(bridgeTimeout), + // ... +}); +``` + +```go +// cloud_agent_runtime.go +request.BridgeTimeout = 15 * time.Minute // 媒体生成给 15 分钟 +``` + +--- + +#### 10. ⏸️ 并发上限(反馈第 10 项) +**最优解决方案**:环境变量 + 排队 +```go +var ( + maxConcurrentAgents = getEnvInt("CANVAS_MAX_CONCURRENT_AGENTS", 100) + agentSemaphore = make(chan struct{}, maxConcurrentAgents) +) + +func (s *Service) runCloudAgentModelStep(...) error { + select { + case agentSemaphore <- struct{}{}: + defer func() { <-agentSemaphore }() + case <-ctx.Done(): + return fmt.Errorf("等待 Agent 资源超时") + } + + // 执行 +} +``` + +--- + +#### 11. ⏸️ 内存上限(反馈第 11 项) +**最优解决方案**:Node 启动参数 +```go +cmd := exec.CommandContext(ctx, "node", + "--max-old-space-size=2048", // 限制 2GB + filepath.Join(runtimeDir, "agent-runtime.mjs")) +``` + +--- + +#### 12. ⏸️ Session JSONL 增量写入(反馈第 12 项) +**问题**:会话文件可能几 MB,每次全量读写低效 + +**最优解决方案**:只传增量 +```javascript +// SDK 已支持 sessionFile 增量追加 +const sessionConfig = { + sessionManager: new SessionManager({ + sessionFile: request.sessionFile, // SDK 会追加写入 + }), +}; +``` + +```go +// Go 侧只传文件路径,不传全文 +request.SessionFile = filepath.Join(sessionDir, runID+".jsonl") +// 不再设置 request.SessionJSONL +``` + +--- + +## 下一步建议 + +### 立即提交(核心修复) +1. ✅ stdout/Wait 竞态修复 +2. ✅ 运行时启动崩溃修复 +3. ✅ 安全加固(工具白名单 + 环境隔离) +4. ✅ 媒体审批统一 + +### 不要删除独立容器 +`yingce-agent/` 和 `backend/internal/agent/yingceagent/` 是要求保留的独立部署实现。 +不要因为实现不完整就删除目录或撤掉 Compose、镜像构建和 Host Updater 集成;缺的部分应补上。 +远程服务失败时不要自动退回内嵌 Node,否则同一步可能执行两次。 + +### 后续迭代(按优先级) +1. 恢复幂等性(防止重复建单) +2. 审批竞态修复(乐观锁) +3. 媒体 waiter 恢复逻辑 +4. yingce-agent 独立容器(完整实现) +5. 并发上限 + 内存上限 +6. Session JSONL 增量写入 + +--- + +## 提交清单 + +**核心修复**: +```bash +git add backend/agent-runtime/pi/agent-runtime.mjs +git add backend/internal/agent/runtime/runtime.go +git add backend/internal/agent/runtime/runtime_test.go +git add backend/internal/app/cloud_agent_pi_coordinator.go +git add backend/internal/app/cloud_agent_runtime.go +git add backend/internal/app/cloud_agent_media_test.go +git add backend/internal/app/cloud_agent_single_active_task_test.go +git add backend/internal/app/cloud_agent_tools.go +git add backend/internal/prompts/agent-media-policy.md +git add backend/internal/prompts/agent_policy_test.go +git add docs/AGENT_LOCAL_DEVELOPMENT.md +git add AGENTS.md +``` + +**排除**: +```bash +git restore backend/server +git restore .github/workflows/publish-images.yml +git restore docker-compose.deploy.yml +git restore backend/internal/hostupdate/manager_ops.go +rm -rf yingce-agent/ backend/internal/agent/yingceagent/ +rm -f AGENT_RUNTIME_*.md *.md +``` + +你想让我先撤回 yingce-agent 相关改动,还是继续完成完整的独立容器实现? diff --git a/backend/agent-runtime/pi/pnpm-lock.yaml b/backend/agent-runtime/pi/pnpm-lock.yaml new file mode 100644 index 000000000..ea08f7b82 --- /dev/null +++ b/backend/agent-runtime/pi/pnpm-lock.yaml @@ -0,0 +1,1342 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@earendil-works/pi-ai': + specifier: 0.87.1 + version: 0.87.1(ws@8.22.0) + '@earendil-works/pi-coding-agent': + specifier: 0.87.1 + version: 0.87.1(ws@8.22.0) + typebox: + specifier: 1.3.27 + version: 1.3.27 + +packages: + + '@anthropic-ai/sdk@0.124.0': + resolution: {integrity: sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + resolution: {integrity: sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.978.1': + resolution: {integrity: sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.72': + resolution: {integrity: sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.74': + resolution: {integrity: sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.17': + resolution: {integrity: sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.79': + resolution: {integrity: sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.84': + resolution: {integrity: sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.72': + resolution: {integrity: sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.16': + resolution: {integrity: sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.78': + resolution: {integrity: sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.35': + resolution: {integrity: sha512-a8xilRoRaalvSPZdfrs0VY3/BPc0uYhXW56Z/k6nmZ5Vk430LHeowf9APIPq8utM3tMJSNGSSuJMlB9YrPsN+A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.30': + resolution: {integrity: sha512-B6gvZlcnRBNWraKgyEjKsbhv+VjtbyHmpU7hmtTHzXpDSxHPpCJnYLpglEOOsF+SFne20DbZC5IVC1aNr2Pb4A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.54': + resolution: {integrity: sha512-bhESdru8u8KosziH8jcVta/NM/dNreeRz2+2aU86si5bfBYOiJ6IiqD+9U4URUcU5WJO3Yw+jO3GLDIHlscXMQ==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.46': + resolution: {integrity: sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.47': + resolution: {integrity: sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1127.0': + resolution: {integrity: sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1138.0': + resolution: {integrity: sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.974.6': + resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.41': + resolution: {integrity: sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} + engines: {node: '>=18.0.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@earendil-works/chord@0.87.1': + resolution: {integrity: sha512-bg7IkJGFcEaMqqYgOGUiq5Ky9RghpRfrlZ8I/v/1b4bBZ02A7t3E+6uhPRbadwWb/kWsnVFbZsqOKRN4a3LLCg==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-agent-core@0.87.1': + resolution: {integrity: sha512-Zev3B0HK7YS5A4EZQ2XnEqiJuirx6QBiltJ+LpmjV5a/+2IU0cfKtIfnkNkORK707XOvKBY2WRtk7cAwHpbh2Q==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-ai@0.87.1': + resolution: {integrity: sha512-X/3PfQBnnoeVdO9Cv8zHghUMglzlgNZYGNzoPnbRoGnHl3Rw3TlA2UKSUB7BRHUOxMryHXYa8dnjWZlbRheDZA==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-coding-agent@0.87.1': + resolution: {integrity: sha512-m8ArJUtVcQMSe1lLE/Ei7vX/JV7O39sWmWBsXV2NOU70F0qCp8GubA24pT3LnwTmM6LL2xV80/h6sQg85n69ew==} + engines: {node: '>=22.19.0'} + hasBin: true + + '@earendil-works/pi-telemetry@0.87.1': + resolution: {integrity: sha512-MC6TRQH5lgMXpcN+Vku2WMI2T8BsiUPzMQHGo81uqFZD3/9O79WWJAysEDGuzduP6R4tvtgwMLwmqIxynM10JQ==} + engines: {node: '>=22.19.0'} + + '@earendil-works/pi-tui@0.87.1': + resolution: {integrity: sha512-YEH2vRyOeiO7hhN6j6AE6YwKSq2Kz2f3XR8bj1TbR+aGE/JsnY1hLPMI2pvaZfRM1n9Y00tejxFQ4zbzvF7nkQ==} + engines: {node: '>=22.19.0'} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@google/genai@2.21.0': + resolution: {integrity: sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==} + engines: {node: '>=20.0.0'} + peerDependencies: + '@modelcontextprotocol/sdk': ^1.25.2 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + + '@silvia-odwyer/photon-node@0.3.4': + resolution: {integrity: sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==} + + '@smithy/core@3.35.0': + resolution: {integrity: sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.5.2': + resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.8.0': + resolution: {integrity: sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.12.1': + resolution: {integrity: sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.7.4': + resolution: {integrity: sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.19.0': + resolution: {integrity: sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==} + engines: {node: '>=18.0.0'} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@types/node@26.6.3': + resolution: {integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==} + + '@types/retry@0.12.0': + resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agent-base@9.0.0: + resolution: {integrity: sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==} + engines: {node: '>= 20'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + gaxios@7.3.1: + resolution: {integrity: sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.2: + resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} + engines: {node: '>=18'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + google-auth-library@10.9.1: + resolution: {integrity: sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==} + engines: {node: '>=18'} + + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + grok-mermaid@0.2.3: + resolution: {integrity: sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==} + engines: {node: '>=18'} + + highlight.js@10.7.3: + resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} + + hosted-git-info@9.0.3: + resolution: {integrity: sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==} + engines: {node: ^20.17.0 || >=22.9.0} + + http-proxy-agent@9.1.0: + resolution: {integrity: sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==} + engines: {node: '>= 20'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + https-proxy-agent@9.1.0: + resolution: {integrity: sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==} + engines: {node: '>= 20'} + + ignore@7.0.8: + resolution: {integrity: sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==} + engines: {node: '>= 4'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + marked@18.0.11: + resolution: {integrity: sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==} + engines: {node: '>= 20'} + hasBin: true + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + openai@6.40.0: + resolution: {integrity: sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==} + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + + p-retry@4.6.2: + resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} + engines: {node: '>=8'} + + partial-json@0.1.7: + resolution: {integrity: sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + + proxy-agent-negotiate@1.1.0: + resolution: {integrity: sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==} + engines: {node: '>= 20'} + peerDependencies: + kerberos: ^2.0.0 + peerDependenciesMeta: + kerberos: + optional: true + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + standardwebhooks@1.1.1: + resolution: {integrity: sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typebox@1.3.27: + resolution: {integrity: sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==} + + undici-types@8.9.0: + resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} + + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + ws@8.22.0: + resolution: {integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@anthropic-ai/sdk@0.124.0': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.1.1 + + '@aws-sdk/client-bedrock-runtime@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-node': 3.972.84 + '@aws-sdk/eventstream-handler-node': 3.972.35 + '@aws-sdk/middleware-eventstream': 3.972.30 + '@aws-sdk/middleware-websocket': 3.972.54 + '@aws-sdk/token-providers': 3.1127.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.978.1': + dependencies: + '@aws-sdk/types': 3.974.6 + '@aws-sdk/xml-builder': 3.972.41 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.35.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.72': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.74': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.17': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-login': 3.972.79 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.79': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.84': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.72 + '@aws-sdk/credential-provider-http': 3.972.74 + '@aws-sdk/credential-provider-ini': 3.973.17 + '@aws-sdk/credential-provider-process': 3.972.72 + '@aws-sdk/credential-provider-sso': 3.973.16 + '@aws-sdk/credential-provider-web-identity': 3.972.78 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/credential-provider-imds': 4.5.2 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.72': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.16': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/token-providers': 3.1138.0 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.78': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.35': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.30': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.54': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.46': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/signature-v4-multi-region': 3.996.47 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/fetch-http-handler': 5.8.0 + '@smithy/node-http-handler': 4.12.1 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.47': + dependencies: + '@aws-sdk/types': 3.974.6 + '@smithy/signature-v4': 5.7.4 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1127.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1138.0': + dependencies: + '@aws-sdk/core': 3.978.1 + '@aws-sdk/nested-clients': 3.997.46 + '@aws-sdk/types': 3.974.6 + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/types@3.974.6': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.41': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.3.0': {} + + '@babel/runtime@7.29.7': {} + + '@earendil-works/chord@0.87.1': + dependencies: + esbuild: 0.28.2 + + '@earendil-works/pi-agent-core@0.87.1(ws@8.22.0)': + dependencies: + '@earendil-works/chord': 0.87.1 + '@earendil-works/pi-ai': 0.87.1(ws@8.22.0) + '@earendil-works/pi-telemetry': 0.87.1 + diff: 8.0.4 + ignore: 7.0.8 + typebox: 1.3.27 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - kerberos + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-ai@0.87.1(ws@8.22.0)': + dependencies: + '@anthropic-ai/sdk': 0.124.0 + '@aws-sdk/client-bedrock-runtime': 3.1127.0 + '@earendil-works/pi-telemetry': 0.87.1 + '@google/genai': 2.21.0 + '@smithy/node-http-handler': 4.12.1 + http-proxy-agent: 9.1.0 + https-proxy-agent: 9.1.0 + openai: 6.40.0(ws@8.22.0) + partial-json: 0.1.7 + typebox: 1.3.27 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - kerberos + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-coding-agent@0.87.1(ws@8.22.0)': + dependencies: + '@earendil-works/chord': 0.87.1 + '@earendil-works/pi-agent-core': 0.87.1(ws@8.22.0) + '@earendil-works/pi-ai': 0.87.1(ws@8.22.0) + '@earendil-works/pi-tui': 0.87.1 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 6.0.0 + cross-spawn: 7.0.6 + diff: 8.0.4 + grok-mermaid: 0.2.3 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.8 + jiti: 2.7.0 + minimatch: 10.2.6 + proper-lockfile: 4.1.2 + semver: 7.8.5 + typebox: 1.3.27 + undici: 8.10.2 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - kerberos + - supports-color + - utf-8-validate + - ws + - zod + + '@earendil-works/pi-telemetry@0.87.1': {} + + '@earendil-works/pi-tui@0.87.1': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.11 + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@google/genai@2.21.0': + dependencies: + google-auth-library: 10.9.1 + p-retry: 4.6.2 + protobufjs: 7.6.6 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + + '@silvia-odwyer/photon-node@0.3.4': {} + + '@smithy/core@3.35.0': + dependencies: + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.5.2': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.8.0': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.12.1': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.7.4': + dependencies: + '@smithy/core': 3.35.0 + '@smithy/types': 4.19.0 + tslib: 2.8.1 + + '@smithy/types@4.19.0': + dependencies: + tslib: 2.8.1 + + '@stablelib/base64@1.0.1': {} + + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 + + '@types/retry@0.12.0': {} + + agent-base@7.1.4: {} + + agent-base@9.0.0: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + bignumber.js@9.3.1: {} + + bowser@2.14.1: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-equal-constant-time@1.0.1: {} + + chalk@6.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + data-uri-to-buffer@4.0.1: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + diff@8.0.4: {} + + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + extend@3.0.2: {} + + fast-sha256@1.3.0: {} + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + gaxios@7.3.1: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.2: + dependencies: + gaxios: 7.3.1 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + + get-east-asian-width@1.6.0: {} + + google-auth-library@10.9.1: + dependencies: + base64-js: 1.5.1 + ecdsa-sig-formatter: 1.0.11 + gaxios: 7.3.1 + gcp-metadata: 8.1.2 + google-logging-utils: 1.1.3 + jws: 4.0.1 + transitivePeerDependencies: + - supports-color + + google-logging-utils@1.1.3: {} + + graceful-fs@4.2.11: {} + + grok-mermaid@0.2.3: {} + + highlight.js@10.7.3: {} + + hosted-git-info@9.0.3: + dependencies: + lru-cache: 11.5.3 + + http-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@9.1.0: + dependencies: + agent-base: 9.0.0 + debug: 4.4.3 + proxy-agent-negotiate: 1.1.0 + transitivePeerDependencies: + - kerberos + - supports-color + + ignore@7.0.8: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + long@5.3.2: {} + + lru-cache@11.5.3: {} + + marked@18.0.11: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + ms@2.1.3: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + openai@6.40.0(ws@8.22.0): + optionalDependencies: + ws: 8.22.0 + + p-retry@4.6.2: + dependencies: + '@types/retry': 0.12.0 + retry: 0.13.1 + + partial-json@0.1.7: {} + + path-key@3.1.1: {} + + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 26.6.3 + long: 5.3.2 + + proxy-agent-negotiate@1.1.0: {} + + retry@0.12.0: {} + + retry@0.13.1: {} + + safe-buffer@5.2.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + standardwebhooks@1.1.1: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + ts-algebra@2.0.0: {} + + tslib@2.8.1: {} + + typebox@1.3.27: {} + + undici-types@8.9.0: {} + + undici@8.10.2: {} + + web-streams-polyfill@3.3.3: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + ws@8.22.0: {} + + yaml@2.9.0: {} diff --git a/backend/internal/app/admin.go b/backend/internal/app/admin.go index 8f9f3bb8e..32f600129 100644 --- a/backend/internal/app/admin.go +++ b/backend/internal/app/admin.go @@ -2,17 +2,11 @@ package app import ( "encoding/json" - "errors" - "fmt" - "infinite-canvas/backend/internal/kernel" - stdlog "log" "strings" "time" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/repository" - - "gorm.io/gorm" ) type CreateAdminUserRequest struct { @@ -23,6 +17,7 @@ type CreateAdminUserRequest struct { Role model.UserRole `json:"role"` Status model.UserStatus `json:"status"` } + type UpdateUserRequest struct { DisplayName string `json:"displayName"` Email string `json:"email"` @@ -137,736 +132,6 @@ type PublicChannelModelPrice struct { CapabilityConfig *ModelCapabilityConfig `json:"capabilityConfig,omitempty"` } -func (s *Service) RequireAdmin(user *model.User) error { - if user == nil { - return Unauthorized("请先登录") - } - if user.Role != model.UserRoleAdmin { - return Forbidden("需要管理员权限") - } - return nil -} - -func (s *Service) AdminUsers(actor *model.User, query AdminListQuery) (*AdminUserPage, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - page, limit := normalizeAdminPage(query.Page, query.Limit) - users, total, err := s.repo.AdminUsers(query.Keyword, model.UserRole(query.Type), model.UserStatus(query.Status), limit, (page-1)*limit) - if err != nil { - return nil, err - } - userIDs := make([]string, 0, len(users)) - for _, user := range users { - userIDs = append(userIDs, user.ID) - } - accounts, err := s.repo.CreditAccounts(userIDs) - if err != nil { - return nil, err - } - accountByUserID := make(map[string]model.CreditAccount, len(accounts)) - for _, account := range accounts { - accountByUserID[account.UserID] = account - } - result := make([]AdminUser, 0, len(users)) - for _, user := range users { - account := accountByUserID[user.ID] - result = append(result, AdminUser{User: user, AvailableMicrocredits: account.AvailableMicrocredits, ReservedMicrocredits: account.ReservedMicrocredits}) - } - return &AdminUserPage{Users: result, Total: total, Page: page, Limit: limit}, nil -} - -func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - users, err := s.repo.AdminUserReferences() - if err != nil { - return nil, err - } - channels, err := s.repo.AdminSystemChannelReferences() - if err != nil { - return nil, err - } - channelIDs := make([]string, 0, len(channels)) - for _, channel := range channels { - channelIDs = append(channelIDs, channel.ID) - } - channelModels, err := s.repo.ChannelModelReferences(channelIDs) - if err != nil { - return nil, err - } - modelsByChannel := make(map[string][]model.ChannelModel, len(channels)) - for _, item := range channelModels { - modelsByChannel[item.ChannelID] = append(modelsByChannel[item.ChannelID], item) - } - result := &AdminReferenceData{ - Users: make([]AdminUserReference, 0, len(users)), - Channels: make([]AdminChannelReference, 0, len(channels)), - } - for _, user := range users { - result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName}) - } - for _, channel := range channels { - items := modelsByChannel[channel.ID] - models := make([]string, 0, len(items)) - displayNames := make([]string, 0, len(items)) - for _, item := range items { - if item.Enabled { - models = append(models, item.ModelKey) - } - displayNames = append(displayNames, firstNonEmpty(strings.TrimSpace(item.DisplayName), item.ModelKey)) - } - result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)}) - } - return result, nil -} - -func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) (*AdminUser, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - username := normalizeUsername(req.Username) - email := normalizeEmail(req.Email) - displayName := normalizeDisplayName(req.DisplayName, username) - if err := validateUsername(username); err != nil { - return nil, err - } - if err := validatePassword(req.Password); err != nil { - return nil, err - } - if email != "" { - if err := validateEmail(email); err != nil { - return nil, err - } - } - if req.Role != model.UserRoleAdmin && req.Role != model.UserRoleUser { - return nil, BadAuthRequest("\u7528\u6237\u89d2\u8272\u65e0\u6548") - } - if req.Status != model.UserStatusActive && req.Status != model.UserStatusDisabled { - return nil, BadAuthRequest("\u7528\u6237\u72b6\u6001\u65e0\u6548") - } - if _, err := s.repo.UserByUsername(username); err == nil { - return nil, BadAuthRequest("\u7528\u6237\u540d\u5df2\u5b58\u5728") - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - if email != "" { - if _, err := s.repo.UserByEmail(email); err == nil { - return nil, BadAuthRequest("\u90ae\u7bb1\u5df2\u88ab\u6ce8\u518c") - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } - passwordHash, err := hashPassword(req.Password) - if err != nil { - return nil, err - } - now := time.Now() - user := &model.User{ - ID: newID(), - Username: username, - Email: email, - DisplayName: displayName, - Role: req.Role, - Status: req.Status, - PasswordHash: passwordHash, - CreatedAt: now, - UpdatedAt: now, - } - if err := s.repo.Create(user); err != nil { - return nil, err - } - if err := s.ensureSignupBonus(user.ID); err != nil { - return nil, err - } - if err := s.appendAdminAudit(actor, "user.create", "user", user.ID, "\u521b\u5efa\u7528\u6237\u8d26\u53f7", map[string]any{"role": user.Role, "status": user.Status}); err != nil { - return nil, err - } - account, err := s.repo.CreditAccount(user.ID) - if err != nil { - return nil, err - } - return &AdminUser{ - User: *user, - AvailableMicrocredits: account.AvailableMicrocredits, - ReservedMicrocredits: account.ReservedMicrocredits, - }, nil -} - -func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserRequest) (*model.User, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - user, err := s.repo.User(userID) - if err != nil { - return nil, err - } - if actor.ID == user.ID && req.Status == model.UserStatusDisabled { - return nil, BadAuthRequest("不能禁用当前管理员账号") - } - nextRole := user.Role - if req.Role == model.UserRoleAdmin || req.Role == model.UserRoleUser { - nextRole = req.Role - } - nextStatus := user.Status - if req.Status == model.UserStatusActive || req.Status == model.UserStatusDisabled { - nextStatus = req.Status - } - if user.Role == model.UserRoleAdmin && nextRole != model.UserRoleAdmin { - count, err := s.repo.ActiveAdminCountExcluding(user.ID) - if err != nil { - return nil, err - } - if count == 0 { - return nil, BadAuthRequest("至少需要保留一个管理员") - } - } - if user.Role == model.UserRoleAdmin && nextStatus != model.UserStatusActive { - count, err := s.repo.ActiveAdminCountExcluding(user.ID) - if err != nil { - return nil, err - } - if count == 0 { - return nil, BadAuthRequest("至少需要保留一个可用管理员") - } - } - if strings.TrimSpace(req.DisplayName) != "" { - user.DisplayName = normalizeDisplayName(req.DisplayName, user.Username) - } - if req.Email != "" { - email := normalizeEmail(req.Email) - if err := validateEmail(email); err != nil { - return nil, err - } - existing, err := s.repo.UserByEmail(email) - if err == nil && existing.ID != user.ID { - return nil, BadAuthRequest("邮箱已被注册") - } - if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - user.Email = email - } - if req.Password != "" { - if err := validatePassword(req.Password); err != nil { - return nil, err - } - hash, err := hashPassword(req.Password) - if err != nil { - return nil, err - } - user.PasswordHash = hash - if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil { - return nil, fmt.Errorf("清理旧登录会话失败,密码未更新:%w", err) - } - } - user.Role = nextRole - user.Status = nextStatus - user.UpdatedAt = time.Now() - if err := s.repo.Save(user); err != nil { - return nil, err - } - if err := s.appendAdminAudit(actor, "user.update", "user", user.ID, "更新用户账号状态或资料", map[string]any{"role": user.Role, "status": user.Status}); err != nil { - return nil, err - } - return user, nil -} - -func (s *Service) DeleteUser(actor *model.User, userID string) error { - if err := s.RequireAdmin(actor); err != nil { - return err - } - if actor.ID == userID { - return BadAuthRequest("不能删除当前登录的管理员账号") - } - user, err := s.repo.User(userID) - if err != nil { - return err - } - if user.Role == model.UserRoleAdmin { - count, err := s.repo.ActiveAdminCountExcluding(user.ID) - if err != nil { - return err - } - if count == 0 { - return BadAuthRequest("至少需要保留一个管理员") - } - } - if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil { - return err - } - if err := s.repo.DeleteUserTaskTextDeltas(user.ID); err != nil { - return err - } - // 有资金流水后必须保留用户主体,删除入口改为停用并清除全部登录态。 - user.Status = model.UserStatusDisabled - user.UpdatedAt = time.Now() - if err := s.repo.Save(user); err != nil { - return err - } - return s.appendAdminAudit(actor, "user.disable", "user", user.ID, "停用用户并清除登录态", nil) -} - -func (s *Service) BulkDisableUsers(actor *model.User, req BulkDisableUsersRequest) (*BulkDisableUsersResult, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - seen := make(map[string]struct{}, len(req.UserIDs)) - userIDs := make([]string, 0, len(req.UserIDs)) - for _, rawID := range req.UserIDs { - id := strings.TrimSpace(rawID) - if id == "" { - return nil, BadAuthRequest("用户 ID 无效") - } - if _, exists := seen[id]; exists { - continue - } - seen[id] = struct{}{} - userIDs = append(userIDs, id) - } - if len(userIDs) == 0 { - return nil, BadAuthRequest("请选择要停用的用户") - } - if len(userIDs) > 100 { - return nil, BadAuthRequest("单次最多停用 100 个用户") - } - metadata, err := json.Marshal(map[string]any{"userIds": userIDs, "count": len(userIDs)}) - if err != nil { - return nil, err - } - now := time.Now() - events := make([]model.AdminAuditEvent, 0, len(userIDs)) - for _, userID := range userIDs { - events = append(events, model.AdminAuditEvent{ID: newID(), ActorUserID: actor.ID, Action: "user.bulk_disable", TargetType: "user", TargetID: userID, Summary: "批量停用用户并清除登录态", MetadataJSON: string(metadata), CreatedAt: now}) - } - users, err := s.repo.BulkDisableUsers(actor.ID, userIDs, events, now) - if errors.Is(err, repository.ErrBulkUserNotFound) { - return nil, BadAuthRequest("部分用户不存在,请刷新列表后重试") - } - if errors.Is(err, repository.ErrBulkCurrentAdmin) { - return nil, BadAuthRequest("不能停用当前登录的管理员账号") - } - if errors.Is(err, repository.ErrBulkLastActiveAdmin) { - return nil, BadAuthRequest("批量操作后至少需要保留一个可用管理员") - } - if err != nil { - return nil, err - } - return &BulkDisableUsersResult{Users: users, DisabledCount: len(users)}, nil -} - -func (s *Service) PublicSystemChannels() ([]PublicModelChannel, error) { - channels, err := s.repo.SystemChannels(false) - if err != nil { - return nil, err - } - result := make([]PublicModelChannel, 0, len(channels)) - for _, channel := range channels { - items, itemErr := s.repo.ChannelModels(channel.ID, false) - if itemErr != nil { - return nil, itemErr - } - result = append(result, publicChannel(channel, false, items)) - } - return result, nil -} - -func (s *Service) SystemChannel(id string) (*model.ModelChannel, error) { - channel, err := s.repo.SystemChannel(id) - if err != nil { - return nil, err - } - if err := s.decryptSystemChannelSecrets(channel); err != nil { - return nil, err - } - return channel, nil -} - -func (s *Service) adminSystemChannel(id string) (*model.ModelChannel, error) { - channel, err := s.repo.AdminSystemChannel(id) - if err != nil { - return nil, err - } - if err := s.decryptSystemChannelSecrets(channel); err != nil { - return nil, err - } - return channel, nil -} - -func (s *Service) AdminSystemChannelPage(actor *model.User, query AdminListQuery) (*AdminChannelPage, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - page, limit := normalizeAdminPage(query.Page, query.Limit) - channels, total, err := s.repo.AdminSystemChannels(query.Keyword, query.Status, limit, (page-1)*limit) - if err != nil { - return nil, err - } - result := make([]PublicModelChannel, 0, len(channels)) - for _, channel := range channels { - items, itemErr := s.repo.ChannelModels(channel.ID, true) - if itemErr != nil { - return nil, itemErr - } - result = append(result, publicChannel(channel, true, items)) - } - return &AdminChannelPage{Channels: result, Total: total, Page: page, Limit: limit}, nil -} - -func normalizeAdminPage(page int, limit int) (int, int) { - if page <= 0 { - page = 1 - } - if limit <= 0 || limit > 100 { - limit = 20 - } - return page, limit -} - -func (s *Service) CreateSystemChannel(actor *model.User, req ChannelRequest) (*PublicModelChannel, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - channelID, err := s.repo.NextPrefixedID("CHANNEL") - if err != nil { - return nil, err - } - channel, err := s.channelFromRequest(req, model.ModelChannel{ID: channelID, UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true}) - if err != nil { - return nil, err - } - if err := s.encryptSystemChannelSecrets(&channel); err != nil { - return nil, err - } - if err := s.repo.Create(&channel); err != nil { - return nil, err - } - if err := s.syncInitialChannelModels(&channel, req.Models); err != nil { - return nil, err - } - s.invalidateRouteCatalog() - items, err := s.repo.ChannelModels(channel.ID, true) - if err != nil { - return nil, err - } - public := publicChannel(channel, true, items) - return &public, nil -} - -func (s *Service) DuplicateSystemChannel(actor *model.User, id string) (*PublicModelChannel, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - source, err := s.adminSystemChannel(id) - if err != nil { - return nil, err - } - sourceModels, err := s.repo.ChannelModels(source.ID, true) - if err != nil { - return nil, err - } - if len(sourceModels) == 0 { - for _, name := range channelModelNames(*source) { - sourceModels = append(sourceModels, model.ChannelModel{ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) - } - } - channelID, err := s.repo.NextPrefixedID("CHANNEL") - if err != nil { - return nil, err - } - channel := *source - channel.ID = channelID - channel.UserID = actor.ID - channel.Scope = model.ChannelScopeSystem - channel.Name = duplicateChannelName(source.Name) - channel.CreatedAt = time.Time{} - channel.UpdatedAt = time.Time{} - channel.DeletedAt = gorm.DeletedAt{} - if err := s.encryptSystemChannelSecrets(&channel); err != nil { - return nil, err - } - - channelModels := make([]model.ChannelModel, 0, len(sourceModels)) - priceTiers := make([]model.ChannelModelPriceTier, 0) - for _, sourceModel := range sourceModels { - modelID, idErr := s.repo.NextPrefixedID("MODEL") - if idErr != nil { - return nil, idErr - } - channelModel := sourceModel - channelModel.ID = modelID - channelModel.ChannelID = channel.ID - channelModel.CreatedAt = time.Time{} - channelModel.UpdatedAt = time.Time{} - channelModel.DeletedAt = gorm.DeletedAt{} - channelModel.PriceTiers = nil - channelModels = append(channelModels, channelModel) - for _, sourceTier := range sourceModel.PriceTiers { - tierID, tierErr := s.repo.NextPrefixedID("PTIER") - if tierErr != nil { - return nil, tierErr - } - priceTier := sourceTier - priceTier.ID = tierID - priceTier.ChannelModelID = channelModel.ID - priceTier.Selector = nil - priceTier.CreatedAt = time.Time{} - priceTier.UpdatedAt = time.Time{} - priceTier.DeletedAt = gorm.DeletedAt{} - priceTiers = append(priceTiers, priceTier) - } - } - if err := s.repo.CreateDuplicatedSystemChannel(&channel, channelModels, priceTiers); err != nil { - return nil, err - } - s.invalidateRouteCatalog() - items, err := s.repo.ChannelModels(channel.ID, true) - if err != nil { - return nil, err - } - public := publicChannel(channel, true, items) - return &public, nil -} - -func duplicateChannelName(name string) string { - const suffix = " - 副本" - base := []rune(strings.TrimSpace(name)) - if len(base) == 0 { - base = []rune("系统渠道") - } - if len(base)+len([]rune(suffix)) > 80 { - base = base[:80-len([]rune(suffix))] - } - return string(base) + suffix -} - -func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelRequest) (*PublicModelChannel, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - if req.presentationOnly() { - return s.updateChannelPresentation(id, req) - } - updateModels := req.Models != nil - channel, err := s.repo.AdminSystemChannel(id) - if err != nil { - return nil, err - } - if err := s.decryptSystemChannelSecrets(channel); err != nil { - return nil, err - } - req = mergeChannelRequest(req, *channel) - next, err := s.channelFromRequest(req, *channel) - if err != nil { - return nil, err - } - next.ID = channel.ID - next.UserID = channel.UserID - next.Scope = model.ChannelScopeSystem - next.CreatedAt = channel.CreatedAt - if req.APIKey == "" { - next.APIKey = channel.APIKey - } - if req.SecretKey == "" { - next.SecretKey = channel.SecretKey - } - if err := s.encryptSystemChannelSecrets(&next); err != nil { - return nil, err - } - if err := s.repo.Save(&next); err != nil { - return nil, err - } - if updateModels { - if err := s.syncInitialChannelModels(&next, req.Models); err != nil { - return nil, err - } - } - s.invalidateRouteCatalog() - items, err := s.repo.ChannelModels(next.ID, true) - if err != nil { - return nil, err - } - public := publicChannel(next, true, items) - return &public, nil -} - -func (s *Service) encryptSystemChannelSecrets(channel *model.ModelChannel) error { - apiKey, err := s.encryptSettingSecret(channel.APIKey) - if err != nil { - return err - } - secretKey, err := s.encryptSettingSecret(channel.SecretKey) - if err != nil { - return err - } - channel.APIKey = apiKey - channel.SecretKey = secretKey - return nil -} - -func (s *Service) decryptSystemChannelSecrets(channel *model.ModelChannel) error { - apiKey, err := s.decryptSettingSecret(channel.APIKey) - if err != nil { - return err - } - secretKey, err := s.decryptSettingSecret(channel.SecretKey) - if err != nil { - return err - } - channel.APIKey = apiKey - channel.SecretKey = secretKey - return nil -} - -func (s *Service) DeleteSystemChannel(actor *model.User, id string) error { - if err := s.RequireAdmin(actor); err != nil { - return err - } - channel, err := s.repo.AdminSystemChannel(id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("系统渠道不存在或已删除") - } - return err - } - // 保留主体供历史账单和调用日志关联,但从所有业务查询中隐藏并清除密钥。 - err = s.repo.DeleteSystemChannel(channel.ID) - if errors.Is(err, gorm.ErrRecordNotFound) { - return BadAuthRequest("系统渠道不存在或已删除") - } - if err == nil { - s.invalidateRouteCatalog() - } - return err -} - -func (s *Service) LogAPICall(log model.ApiCallLog) error { - if log.ID == "" { - log.ID = newID() - } - if log.CreatedAt.IsZero() { - log.CreatedAt = time.Now() - } - if log.StartedAt.IsZero() { - log.StartedAt = log.CreatedAt.Add(-time.Duration(log.DurationMs) * time.Millisecond) - } - s.estimateCallCost(&log) - if log.BillingOrderID != "" && log.ProviderRequestID != "" { - if err := s.repo.UpdateBillingProviderRequestID(log.BillingOrderID, log.ProviderRequestID); err != nil { - // 账单关联是请求日志的辅助状态,不能因为关联更新失败而丢失 - // 已经发生的上游调用记录。后续由任务/账单对账流程补偿关联。 - stdlog.Printf("provider billing request id update failed: billing_order_id=%s provider_request_id=%s error=%v", log.BillingOrderID, log.ProviderRequestID, err) - } - } - if log.TaskID != "" && (log.RequestKind == "create" || log.RequestKind == "poll" || log.RequestKind == "cancel") { - stage := log.RequestKind - var nextPollAt *time.Time - if stage == "create" && log.Status == model.ApiCallStatusSucceeded && log.ProviderRequestID != "" { - stage = "accepted" - delay := 2 * time.Second - if log.Capability == "video" { - delay = defaultVideoPollInterval - } - next := time.Now().Add(delay) - nextPollAt = &next - } else if stage == "poll" { - delay := 5 * time.Second - if log.Capability == "video" { - delay = defaultVideoPollInterval - } - next := time.Now().Add(delay) - nextPollAt = &next - } - if err := s.repo.UpdateTaskProviderState(log.TaskID, log.ProviderRequestID, stage, nextPollAt); err != nil { - // 请求日志本身仍需保留;任务状态可由后续任务收尾或恢复流程 - // 重建,不能让一次状态写失败掩盖真实的上游调用。 - stdlog.Printf("provider task state update failed: task_id=%s provider_request_id=%s error=%v", log.TaskID, log.ProviderRequestID, err) - } - } - if merged, err := s.mergeVideoAPICallLog(log); err != nil { - return err - } else if merged { - return nil - } - policy, err := s.RuntimePolicy() - if err != nil { - return err - } - s.storageMu.Lock() - defer s.storageMu.Unlock() - usage, err := s.repo.UserStorageUsage(log.UserID) - if err != nil { - return err - } - incomingBytes := int64(len(log.Path) + len(log.Model) + len(log.ProviderRequestID) + len(log.ErrorCode) + len(log.Error) + len(log.UpstreamURL) + len(log.RequestContentType) + len(log.RequestBody) + len(log.ResponseBody)) - if err := validateAPICallLogQuotaWithPolicy(usage, incomingBytes, policy.Resource); err != nil { - return err - } - return s.repo.Create(&log) -} - -func (s *Service) mergeVideoAPICallLog(log model.ApiCallLog) (bool, error) { - // Delivery is a separate outcome: a failed download must not rewrite a - // successful generation request as failed. - if log.Capability != "video" || log.RequestKind != "poll" { - return false, nil - } - if log.TaskID == "" && log.ProviderRequestID == "" { - return false, nil - } - root, err := s.repo.VideoAPICallRoot(log) - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - if err != nil { - return false, err - } - if log.RequestKind == "poll" { - root.PollCount++ - if log.ResponseBody != "" { - root.ResponseBody = log.ResponseBody - } - } - if log.ProviderRequestID != "" { - root.ProviderRequestID = log.ProviderRequestID - } - if log.ProviderStatus != "" { - root.ProviderStatus = log.ProviderStatus - } - startedAt := root.StartedAt - if startedAt.IsZero() { - startedAt = root.CreatedAt.Add(-time.Duration(root.DurationMs) * time.Millisecond) - root.StartedAt = startedAt - } - root.DurationMs = max(root.DurationMs, log.CreatedAt.Sub(startedAt).Milliseconds()) - root.StatusCode = log.StatusCode - root.ConcurrencyLimit = log.ConcurrencyLimit - if log.Status == model.ApiCallStatusFailed { - root.Status = log.Status - root.ErrorCode = log.ErrorCode - root.Error = log.Error - } else { - root.Status = model.ApiCallStatusSucceeded - root.ErrorCode = "" - root.Error = "" - } - if log.UsageAvailable { - root.UsageAvailable = true - root.InputTokens = log.InputTokens - root.OutputTokens = log.OutputTokens - root.CachedTokens = log.CachedTokens - } - return true, s.repo.Save(root) -} - -func (s *Service) APICallLogs(actor *model.User, limit int) ([]model.ApiCallLog, error) { - if actor == nil { - return nil, Unauthorized("请先登录") - } - return s.repo.ApiCallLogs(actor.ID, actor.Role == model.UserRoleAdmin, limit) -} - func channelFromRequest(req ChannelRequest, channel model.ModelChannel) (model.ModelChannel, error) { return (&Service{}).channelFromRequest(req, channel) } diff --git a/backend/internal/app/admin_api_call_log.go b/backend/internal/app/admin_api_call_log.go new file mode 100644 index 000000000..a0b533c16 --- /dev/null +++ b/backend/internal/app/admin_api_call_log.go @@ -0,0 +1,138 @@ +// 上游调用日志写入(LogAPICall)与视频任务多次轮询日志的合并。 + +package app + +import ( + "errors" + stdlog "log" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (s *Service) LogAPICall(log model.ApiCallLog) error { + if log.ID == "" { + log.ID = newID() + } + if log.CreatedAt.IsZero() { + log.CreatedAt = time.Now() + } + if log.StartedAt.IsZero() { + log.StartedAt = log.CreatedAt.Add(-time.Duration(log.DurationMs) * time.Millisecond) + } + s.estimateCallCost(&log) + if log.BillingOrderID != "" && log.ProviderRequestID != "" { + if err := s.repo.UpdateBillingProviderRequestID(log.BillingOrderID, log.ProviderRequestID); err != nil { + // 账单关联是请求日志的辅助状态,不能因为关联更新失败而丢失 + // 已经发生的上游调用记录。后续由任务/账单对账流程补偿关联。 + stdlog.Printf("provider billing request id update failed: billing_order_id=%s provider_request_id=%s error=%v", log.BillingOrderID, log.ProviderRequestID, err) + } + } + if log.TaskID != "" && (log.RequestKind == "create" || log.RequestKind == "poll" || log.RequestKind == "cancel") { + stage := log.RequestKind + var nextPollAt *time.Time + if stage == "create" && log.Status == model.ApiCallStatusSucceeded && log.ProviderRequestID != "" { + stage = "accepted" + delay := 2 * time.Second + if log.Capability == "video" { + delay = defaultVideoPollInterval + } + next := time.Now().Add(delay) + nextPollAt = &next + } else if stage == "poll" { + delay := 5 * time.Second + if log.Capability == "video" { + delay = defaultVideoPollInterval + } + next := time.Now().Add(delay) + nextPollAt = &next + } + if err := s.repo.UpdateTaskProviderState(log.TaskID, log.ProviderRequestID, stage, nextPollAt); err != nil { + // 请求日志本身仍需保留;任务状态可由后续任务收尾或恢复流程 + // 重建,不能让一次状态写失败掩盖真实的上游调用。 + stdlog.Printf("provider task state update failed: task_id=%s provider_request_id=%s error=%v", log.TaskID, log.ProviderRequestID, err) + } + } + if merged, err := s.mergeVideoAPICallLog(log); err != nil { + return err + } else if merged { + return nil + } + policy, err := s.RuntimePolicy() + if err != nil { + return err + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + usage, err := s.repo.UserStorageUsage(log.UserID) + if err != nil { + return err + } + incomingBytes := int64(len(log.Path) + len(log.Model) + len(log.ProviderRequestID) + len(log.ErrorCode) + len(log.Error) + len(log.UpstreamURL) + len(log.RequestContentType) + len(log.RequestBody) + len(log.ResponseBody)) + if err := validateAPICallLogQuotaWithPolicy(usage, incomingBytes, policy.Resource); err != nil { + return err + } + return s.repo.Create(&log) +} + +func (s *Service) mergeVideoAPICallLog(log model.ApiCallLog) (bool, error) { + // Delivery is a separate outcome: a failed download must not rewrite a + // successful generation request as failed. + if log.Capability != "video" || log.RequestKind != "poll" { + return false, nil + } + if log.TaskID == "" && log.ProviderRequestID == "" { + return false, nil + } + root, err := s.repo.VideoAPICallRoot(log) + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + if err != nil { + return false, err + } + if log.RequestKind == "poll" { + root.PollCount++ + if log.ResponseBody != "" { + root.ResponseBody = log.ResponseBody + } + } + if log.ProviderRequestID != "" { + root.ProviderRequestID = log.ProviderRequestID + } + if log.ProviderStatus != "" { + root.ProviderStatus = log.ProviderStatus + } + startedAt := root.StartedAt + if startedAt.IsZero() { + startedAt = root.CreatedAt.Add(-time.Duration(root.DurationMs) * time.Millisecond) + root.StartedAt = startedAt + } + root.DurationMs = max(root.DurationMs, log.CreatedAt.Sub(startedAt).Milliseconds()) + root.StatusCode = log.StatusCode + root.ConcurrencyLimit = log.ConcurrencyLimit + if log.Status == model.ApiCallStatusFailed { + root.Status = log.Status + root.ErrorCode = log.ErrorCode + root.Error = log.Error + } else { + root.Status = model.ApiCallStatusSucceeded + root.ErrorCode = "" + root.Error = "" + } + if log.UsageAvailable { + root.UsageAvailable = true + root.InputTokens = log.InputTokens + root.OutputTokens = log.OutputTokens + root.CachedTokens = log.CachedTokens + } + return true, s.repo.Save(root) +} + +func (s *Service) APICallLogs(actor *model.User, limit int) ([]model.ApiCallLog, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + return s.repo.ApiCallLogs(actor.ID, actor.Role == model.UserRoleAdmin, limit) +} diff --git a/backend/internal/app/admin_channels.go b/backend/internal/app/admin_channels.go new file mode 100644 index 000000000..1d2143b50 --- /dev/null +++ b/backend/internal/app/admin_channels.go @@ -0,0 +1,299 @@ +// 管理端系统渠道:增删改、复制、密钥加解密与前台投影。 +// +// 渠道密钥只以密文入库;前台投影(publicChannel)永远不包含密钥与上游地址。 + +package app + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (s *Service) PublicSystemChannels() ([]PublicModelChannel, error) { + channels, err := s.repo.SystemChannels(false) + if err != nil { + return nil, err + } + result := make([]PublicModelChannel, 0, len(channels)) + for _, channel := range channels { + items, itemErr := s.repo.ChannelModels(channel.ID, false) + if itemErr != nil { + return nil, itemErr + } + result = append(result, publicChannel(channel, false, items)) + } + return result, nil +} + +func (s *Service) SystemChannel(id string) (*model.ModelChannel, error) { + channel, err := s.repo.SystemChannel(id) + if err != nil { + return nil, err + } + if err := s.decryptSystemChannelSecrets(channel); err != nil { + return nil, err + } + return channel, nil +} + +func (s *Service) adminSystemChannel(id string) (*model.ModelChannel, error) { + channel, err := s.repo.AdminSystemChannel(id) + if err != nil { + return nil, err + } + if err := s.decryptSystemChannelSecrets(channel); err != nil { + return nil, err + } + return channel, nil +} + +func (s *Service) AdminSystemChannelPage(actor *model.User, query AdminListQuery) (*AdminChannelPage, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + page, limit := normalizeAdminPage(query.Page, query.Limit) + channels, total, err := s.repo.AdminSystemChannels(query.Keyword, query.Status, limit, (page-1)*limit) + if err != nil { + return nil, err + } + result := make([]PublicModelChannel, 0, len(channels)) + for _, channel := range channels { + items, itemErr := s.repo.ChannelModels(channel.ID, true) + if itemErr != nil { + return nil, itemErr + } + result = append(result, publicChannel(channel, true, items)) + } + return &AdminChannelPage{Channels: result, Total: total, Page: page, Limit: limit}, nil +} + +func normalizeAdminPage(page int, limit int) (int, int) { + if page <= 0 { + page = 1 + } + if limit <= 0 || limit > 100 { + limit = 20 + } + return page, limit +} + +func (s *Service) CreateSystemChannel(actor *model.User, req ChannelRequest) (*PublicModelChannel, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + channelID, err := s.repo.NextPrefixedID("CHANNEL") + if err != nil { + return nil, err + } + channel, err := s.channelFromRequest(req, model.ModelChannel{ID: channelID, UserID: actor.ID, Scope: model.ChannelScopeSystem, Enabled: true}) + if err != nil { + return nil, err + } + if err := s.encryptSystemChannelSecrets(&channel); err != nil { + return nil, err + } + if err := s.repo.Create(&channel); err != nil { + return nil, err + } + if err := s.syncInitialChannelModels(&channel, req.Models); err != nil { + return nil, err + } + s.invalidateRouteCatalog() + items, err := s.repo.ChannelModels(channel.ID, true) + if err != nil { + return nil, err + } + public := publicChannel(channel, true, items) + return &public, nil +} + +func (s *Service) DuplicateSystemChannel(actor *model.User, id string) (*PublicModelChannel, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + source, err := s.adminSystemChannel(id) + if err != nil { + return nil, err + } + sourceModels, err := s.repo.ChannelModels(source.ID, true) + if err != nil { + return nil, err + } + if len(sourceModels) == 0 { + for _, name := range channelModelNames(*source) { + sourceModels = append(sourceModels, model.ChannelModel{ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) + } + } + channelID, err := s.repo.NextPrefixedID("CHANNEL") + if err != nil { + return nil, err + } + channel := *source + channel.ID = channelID + channel.UserID = actor.ID + channel.Scope = model.ChannelScopeSystem + channel.Name = duplicateChannelName(source.Name) + channel.CreatedAt = time.Time{} + channel.UpdatedAt = time.Time{} + channel.DeletedAt = gorm.DeletedAt{} + if err := s.encryptSystemChannelSecrets(&channel); err != nil { + return nil, err + } + + channelModels := make([]model.ChannelModel, 0, len(sourceModels)) + priceTiers := make([]model.ChannelModelPriceTier, 0) + for _, sourceModel := range sourceModels { + modelID, idErr := s.repo.NextPrefixedID("MODEL") + if idErr != nil { + return nil, idErr + } + channelModel := sourceModel + channelModel.ID = modelID + channelModel.ChannelID = channel.ID + channelModel.CreatedAt = time.Time{} + channelModel.UpdatedAt = time.Time{} + channelModel.DeletedAt = gorm.DeletedAt{} + channelModel.PriceTiers = nil + channelModels = append(channelModels, channelModel) + for _, sourceTier := range sourceModel.PriceTiers { + tierID, tierErr := s.repo.NextPrefixedID("PTIER") + if tierErr != nil { + return nil, tierErr + } + priceTier := sourceTier + priceTier.ID = tierID + priceTier.ChannelModelID = channelModel.ID + priceTier.Selector = nil + priceTier.CreatedAt = time.Time{} + priceTier.UpdatedAt = time.Time{} + priceTier.DeletedAt = gorm.DeletedAt{} + priceTiers = append(priceTiers, priceTier) + } + } + if err := s.repo.CreateDuplicatedSystemChannel(&channel, channelModels, priceTiers); err != nil { + return nil, err + } + s.invalidateRouteCatalog() + items, err := s.repo.ChannelModels(channel.ID, true) + if err != nil { + return nil, err + } + public := publicChannel(channel, true, items) + return &public, nil +} + +func duplicateChannelName(name string) string { + const suffix = " - 副本" + base := []rune(strings.TrimSpace(name)) + if len(base) == 0 { + base = []rune("系统渠道") + } + if len(base)+len([]rune(suffix)) > 80 { + base = base[:80-len([]rune(suffix))] + } + return string(base) + suffix +} + +func (s *Service) UpdateSystemChannel(actor *model.User, id string, req ChannelRequest) (*PublicModelChannel, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if req.presentationOnly() { + return s.updateChannelPresentation(id, req) + } + updateModels := req.Models != nil + channel, err := s.repo.AdminSystemChannel(id) + if err != nil { + return nil, err + } + if err := s.decryptSystemChannelSecrets(channel); err != nil { + return nil, err + } + req = mergeChannelRequest(req, *channel) + next, err := s.channelFromRequest(req, *channel) + if err != nil { + return nil, err + } + next.ID = channel.ID + next.UserID = channel.UserID + next.Scope = model.ChannelScopeSystem + next.CreatedAt = channel.CreatedAt + if req.APIKey == "" { + next.APIKey = channel.APIKey + } + if req.SecretKey == "" { + next.SecretKey = channel.SecretKey + } + if err := s.encryptSystemChannelSecrets(&next); err != nil { + return nil, err + } + if err := s.repo.Save(&next); err != nil { + return nil, err + } + if updateModels { + if err := s.syncInitialChannelModels(&next, req.Models); err != nil { + return nil, err + } + } + s.invalidateRouteCatalog() + items, err := s.repo.ChannelModels(next.ID, true) + if err != nil { + return nil, err + } + public := publicChannel(next, true, items) + return &public, nil +} + +func (s *Service) encryptSystemChannelSecrets(channel *model.ModelChannel) error { + apiKey, err := s.encryptSettingSecret(channel.APIKey) + if err != nil { + return err + } + secretKey, err := s.encryptSettingSecret(channel.SecretKey) + if err != nil { + return err + } + channel.APIKey = apiKey + channel.SecretKey = secretKey + return nil +} + +func (s *Service) decryptSystemChannelSecrets(channel *model.ModelChannel) error { + apiKey, err := s.decryptSettingSecret(channel.APIKey) + if err != nil { + return err + } + secretKey, err := s.decryptSettingSecret(channel.SecretKey) + if err != nil { + return err + } + channel.APIKey = apiKey + channel.SecretKey = secretKey + return nil +} + +func (s *Service) DeleteSystemChannel(actor *model.User, id string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + channel, err := s.repo.AdminSystemChannel(id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("系统渠道不存在或已删除") + } + return err + } + // 保留主体供历史账单和调用日志关联,但从所有业务查询中隐藏并清除密钥。 + err = s.repo.DeleteSystemChannel(channel.ID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return BadAuthRequest("系统渠道不存在或已删除") + } + if err == nil { + s.invalidateRouteCatalog() + } + return err +} diff --git a/backend/internal/app/admin_users.go b/backend/internal/app/admin_users.go new file mode 100644 index 000000000..a0e1cd8b6 --- /dev/null +++ b/backend/internal/app/admin_users.go @@ -0,0 +1,334 @@ +// 管理端用户管理:列表、创建、更新、删除与批量停用。 + +package app + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func (s *Service) RequireAdmin(user *model.User) error { + if user == nil { + return Unauthorized("请先登录") + } + if user.Role != model.UserRoleAdmin { + return Forbidden("需要管理员权限") + } + return nil +} + +func (s *Service) AdminUsers(actor *model.User, query AdminListQuery) (*AdminUserPage, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + page, limit := normalizeAdminPage(query.Page, query.Limit) + users, total, err := s.repo.AdminUsers(query.Keyword, model.UserRole(query.Type), model.UserStatus(query.Status), limit, (page-1)*limit) + if err != nil { + return nil, err + } + userIDs := make([]string, 0, len(users)) + for _, user := range users { + userIDs = append(userIDs, user.ID) + } + accounts, err := s.repo.CreditAccounts(userIDs) + if err != nil { + return nil, err + } + accountByUserID := make(map[string]model.CreditAccount, len(accounts)) + for _, account := range accounts { + accountByUserID[account.UserID] = account + } + result := make([]AdminUser, 0, len(users)) + for _, user := range users { + account := accountByUserID[user.ID] + result = append(result, AdminUser{User: user, AvailableMicrocredits: account.AvailableMicrocredits, ReservedMicrocredits: account.ReservedMicrocredits}) + } + return &AdminUserPage{Users: result, Total: total, Page: page, Limit: limit}, nil +} + +func (s *Service) AdminReferences(actor *model.User) (*AdminReferenceData, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + users, err := s.repo.AdminUserReferences() + if err != nil { + return nil, err + } + channels, err := s.repo.AdminSystemChannelReferences() + if err != nil { + return nil, err + } + channelIDs := make([]string, 0, len(channels)) + for _, channel := range channels { + channelIDs = append(channelIDs, channel.ID) + } + channelModels, err := s.repo.ChannelModelReferences(channelIDs) + if err != nil { + return nil, err + } + modelsByChannel := make(map[string][]model.ChannelModel, len(channels)) + for _, item := range channelModels { + modelsByChannel[item.ChannelID] = append(modelsByChannel[item.ChannelID], item) + } + result := &AdminReferenceData{ + Users: make([]AdminUserReference, 0, len(users)), + Channels: make([]AdminChannelReference, 0, len(channels)), + } + for _, user := range users { + result.Users = append(result.Users, AdminUserReference{ID: user.ID, Username: user.Username, DisplayName: user.DisplayName}) + } + for _, channel := range channels { + items := modelsByChannel[channel.ID] + models := make([]string, 0, len(items)) + displayNames := make([]string, 0, len(items)) + for _, item := range items { + if item.Enabled { + models = append(models, item.ModelKey) + } + displayNames = append(displayNames, firstNonEmpty(strings.TrimSpace(item.DisplayName), item.ModelKey)) + } + result.Channels = append(result.Channels, AdminChannelReference{ID: channel.ID, Name: channel.Name, Enabled: channel.Enabled, Models: uniqueNonEmpty(models), ModelDisplayNames: uniqueNonEmpty(displayNames)}) + } + return result, nil +} + +func (s *Service) CreateAdminUser(actor *model.User, req CreateAdminUserRequest) (*AdminUser, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + username := normalizeUsername(req.Username) + email := normalizeEmail(req.Email) + displayName := normalizeDisplayName(req.DisplayName, username) + if err := validateUsername(username); err != nil { + return nil, err + } + if err := validatePassword(req.Password); err != nil { + return nil, err + } + if email != "" { + if err := validateEmail(email); err != nil { + return nil, err + } + } + if req.Role != model.UserRoleAdmin && req.Role != model.UserRoleUser { + return nil, BadAuthRequest("\u7528\u6237\u89d2\u8272\u65e0\u6548") + } + if req.Status != model.UserStatusActive && req.Status != model.UserStatusDisabled { + return nil, BadAuthRequest("\u7528\u6237\u72b6\u6001\u65e0\u6548") + } + if _, err := s.repo.UserByUsername(username); err == nil { + return nil, BadAuthRequest("\u7528\u6237\u540d\u5df2\u5b58\u5728") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + if email != "" { + if _, err := s.repo.UserByEmail(email); err == nil { + return nil, BadAuthRequest("\u90ae\u7bb1\u5df2\u88ab\u6ce8\u518c") + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + passwordHash, err := hashPassword(req.Password) + if err != nil { + return nil, err + } + now := time.Now() + user := &model.User{ + ID: newID(), + Username: username, + Email: email, + DisplayName: displayName, + Role: req.Role, + Status: req.Status, + PasswordHash: passwordHash, + CreatedAt: now, + UpdatedAt: now, + } + if err := s.repo.Create(user); err != nil { + return nil, err + } + if err := s.ensureSignupBonus(user.ID); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "user.create", "user", user.ID, "\u521b\u5efa\u7528\u6237\u8d26\u53f7", map[string]any{"role": user.Role, "status": user.Status}); err != nil { + return nil, err + } + account, err := s.repo.CreditAccount(user.ID) + if err != nil { + return nil, err + } + return &AdminUser{ + User: *user, + AvailableMicrocredits: account.AvailableMicrocredits, + ReservedMicrocredits: account.ReservedMicrocredits, + }, nil +} + +func (s *Service) UpdateUser(actor *model.User, userID string, req UpdateUserRequest) (*model.User, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + user, err := s.repo.User(userID) + if err != nil { + return nil, err + } + if actor.ID == user.ID && req.Status == model.UserStatusDisabled { + return nil, BadAuthRequest("不能禁用当前管理员账号") + } + nextRole := user.Role + if req.Role == model.UserRoleAdmin || req.Role == model.UserRoleUser { + nextRole = req.Role + } + nextStatus := user.Status + if req.Status == model.UserStatusActive || req.Status == model.UserStatusDisabled { + nextStatus = req.Status + } + if user.Role == model.UserRoleAdmin && nextRole != model.UserRoleAdmin { + count, err := s.repo.ActiveAdminCountExcluding(user.ID) + if err != nil { + return nil, err + } + if count == 0 { + return nil, BadAuthRequest("至少需要保留一个管理员") + } + } + if user.Role == model.UserRoleAdmin && nextStatus != model.UserStatusActive { + count, err := s.repo.ActiveAdminCountExcluding(user.ID) + if err != nil { + return nil, err + } + if count == 0 { + return nil, BadAuthRequest("至少需要保留一个可用管理员") + } + } + if strings.TrimSpace(req.DisplayName) != "" { + user.DisplayName = normalizeDisplayName(req.DisplayName, user.Username) + } + if req.Email != "" { + email := normalizeEmail(req.Email) + if err := validateEmail(email); err != nil { + return nil, err + } + existing, err := s.repo.UserByEmail(email) + if err == nil && existing.ID != user.ID { + return nil, BadAuthRequest("邮箱已被注册") + } + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + user.Email = email + } + if req.Password != "" { + if err := validatePassword(req.Password); err != nil { + return nil, err + } + hash, err := hashPassword(req.Password) + if err != nil { + return nil, err + } + user.PasswordHash = hash + if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil { + return nil, fmt.Errorf("清理旧登录会话失败,密码未更新:%w", err) + } + } + user.Role = nextRole + user.Status = nextStatus + user.UpdatedAt = time.Now() + if err := s.repo.Save(user); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "user.update", "user", user.ID, "更新用户账号状态或资料", map[string]any{"role": user.Role, "status": user.Status}); err != nil { + return nil, err + } + return user, nil +} + +func (s *Service) DeleteUser(actor *model.User, userID string) error { + if err := s.RequireAdmin(actor); err != nil { + return err + } + if actor.ID == userID { + return BadAuthRequest("不能删除当前登录的管理员账号") + } + user, err := s.repo.User(userID) + if err != nil { + return err + } + if user.Role == model.UserRoleAdmin { + count, err := s.repo.ActiveAdminCountExcluding(user.ID) + if err != nil { + return err + } + if count == 0 { + return BadAuthRequest("至少需要保留一个管理员") + } + } + if err := s.repo.DeleteUserAuthSessions(user.ID); err != nil { + return err + } + if err := s.repo.DeleteUserTaskTextDeltas(user.ID); err != nil { + return err + } + // 有资金流水后必须保留用户主体,删除入口改为停用并清除全部登录态。 + user.Status = model.UserStatusDisabled + user.UpdatedAt = time.Now() + if err := s.repo.Save(user); err != nil { + return err + } + return s.appendAdminAudit(actor, "user.disable", "user", user.ID, "停用用户并清除登录态", nil) +} + +func (s *Service) BulkDisableUsers(actor *model.User, req BulkDisableUsersRequest) (*BulkDisableUsersResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(req.UserIDs)) + userIDs := make([]string, 0, len(req.UserIDs)) + for _, rawID := range req.UserIDs { + id := strings.TrimSpace(rawID) + if id == "" { + return nil, BadAuthRequest("用户 ID 无效") + } + if _, exists := seen[id]; exists { + continue + } + seen[id] = struct{}{} + userIDs = append(userIDs, id) + } + if len(userIDs) == 0 { + return nil, BadAuthRequest("请选择要停用的用户") + } + if len(userIDs) > 100 { + return nil, BadAuthRequest("单次最多停用 100 个用户") + } + metadata, err := json.Marshal(map[string]any{"userIds": userIDs, "count": len(userIDs)}) + if err != nil { + return nil, err + } + now := time.Now() + events := make([]model.AdminAuditEvent, 0, len(userIDs)) + for _, userID := range userIDs { + events = append(events, model.AdminAuditEvent{ID: newID(), ActorUserID: actor.ID, Action: "user.bulk_disable", TargetType: "user", TargetID: userID, Summary: "批量停用用户并清除登录态", MetadataJSON: string(metadata), CreatedAt: now}) + } + users, err := s.repo.BulkDisableUsers(actor.ID, userIDs, events, now) + if errors.Is(err, repository.ErrBulkUserNotFound) { + return nil, BadAuthRequest("部分用户不存在,请刷新列表后重试") + } + if errors.Is(err, repository.ErrBulkCurrentAdmin) { + return nil, BadAuthRequest("不能停用当前登录的管理员账号") + } + if errors.Is(err, repository.ErrBulkLastActiveAdmin) { + return nil, BadAuthRequest("批量操作后至少需要保留一个可用管理员") + } + if err != nil { + return nil, err + } + return &BulkDisableUsersResult{Users: users, DisabledCount: len(users)}, nil +} diff --git a/backend/internal/app/analytics.go b/backend/internal/app/analytics.go index f984b23a5..099b34a19 100644 --- a/backend/internal/app/analytics.go +++ b/backend/internal/app/analytics.go @@ -1,22 +1,11 @@ package app import ( - "bufio" - "bytes" - "encoding/csv" - "encoding/json" - "errors" "log" - "sort" - "strconv" "strings" "time" - "infinite-canvas/backend/internal/assets" "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/repository" - - "gorm.io/gorm" ) type AnalyticsQuery struct { @@ -188,292 +177,6 @@ func (s *Service) AdminAnalytics(actor *model.User, query AnalyticsQuery) (*Anal return result, nil } -func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*APICallLogPage, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" { - return nil, BadAuthRequest("请求明细类型无效") - } - filter := normalizeAnalyticsFilter(query.AnalyticsQuery) - logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit}) - if err != nil { - return nil, err - } - if err := s.decorateAPICallLogs(logs); err != nil { - return nil, err - } - for index := range logs { - // 原始报文只允许通过详情接口按单条读取。 - logs[index].RequestBody = "" - logs[index].ResponseBody = "" - } - page, limit := query.Page, query.Limit - if page <= 0 { - page = 1 - } - if limit <= 0 || limit > 200 { - limit = 50 - } - return &APICallLogPage{Logs: logs, Total: total, Page: page, Limit: limit}, nil -} - -func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { - // 历史日志允许读取逻辑删除渠道的名称,但不读取或返回渠道密钥。 - channels, err := s.repo.HistoricalSystemChannelReferences() - if err != nil { - return err - } - channelNames := make(map[string]string, len(channels)) - for _, channel := range channels { - channelNames[channel.ID] = channel.Name - } - users, err := s.repo.Users() - if err != nil { - return err - } - userByID := make(map[string]model.User, len(users)) - for _, user := range users { - userByID[user.ID] = user - } - billingOrderIDs := make([]string, 0, len(logs)) - seenBillingOrderIDs := make(map[string]struct{}, len(logs)) - taskIDs := make([]string, 0, len(logs)) - seenTaskIDs := make(map[string]struct{}, len(logs)) - for _, log := range logs { - if log.Billable && log.BillingOrderID != "" { - if _, exists := seenBillingOrderIDs[log.BillingOrderID]; !exists { - seenBillingOrderIDs[log.BillingOrderID] = struct{}{} - billingOrderIDs = append(billingOrderIDs, log.BillingOrderID) - } - } - if (log.Capability != "image" && log.Capability != "video" && log.Capability != "audio") || log.TaskID == "" { - continue - } - if _, exists := seenTaskIDs[log.TaskID]; exists { - continue - } - seenTaskIDs[log.TaskID] = struct{}{} - taskIDs = append(taskIDs, log.TaskID) - } - tasks, err := s.repo.APICallLogTasks(taskIDs) - if err != nil { - return err - } - taskByID := make(map[string]model.Task, len(tasks)) - for _, task := range tasks { - taskByID[task.ID] = task - } - billingOrderByID, err := s.repo.BillingOrdersByIDs(billingOrderIDs) - if err != nil { - return err - } - for index := range logs { - if logs[index].StartedAt.IsZero() { - logs[index].StartedAt = logs[index].CreatedAt - } - if logs[index].ChannelID == "" { - logs[index].ChannelName = "自定义渠道" - } else if name := channelNames[logs[index].ChannelID]; name != "" { - logs[index].ChannelName = name - } else { - logs[index].ChannelName = "已删除渠道" - } - if user, exists := userByID[logs[index].UserID]; exists { - logs[index].UserDisplayName = user.DisplayName - logs[index].UserAccount = user.Username - } - if logs[index].Billable { - if order, exists := billingOrderByID[logs[index].BillingOrderID]; exists && order.UserID == logs[index].UserID { - logs[index].BillingAvailable = true - logs[index].BillingStatus = order.Status - if order.ChannelID == logs[index].ChannelID { - logs[index].CreditCostConfigured = order.CostPricing.Configured - cost, costErr := billingCreditCost(order) - if costErr != nil { - return costErr - } - logs[index].CreditCostMicrocredits = cost - } - if order.Status == model.BillingStatusSettled { - logs[index].BillingAmount = order.ActualAmountMicrocredits - } else if order.Status != model.BillingStatusRefunded { - logs[index].BillingAmount = order.ReservedAmountMicrocredits - } - } - } - if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID { - logs[index].TaskStatus = task.Status - logs[index].MediaStage = task.MediaStage - previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) - if canvasResourceID(previewURL) != "" { - logs[index].MediaPreviewURL = "/api/admin/api-logs/" + logs[index].ID + "/media" - logs[index].MediaPreviewKind = previewKind - } else if strings.HasPrefix(previewURL, "https://") || strings.HasPrefix(previewURL, "http://") { - logs[index].MediaPreviewURL = previewURL - logs[index].MediaPreviewKind = previewKind - } - } - } - return nil -} - -// 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。 -func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) { - userID, resource, err := s.adminAPICallLogMediaResource(actor, logID) - if err != nil { - return nil, err - } - return s.openResourceRange(userID, resource, rangeHeader) -} - -func (s *Service) PrepareAdminAPICallLogMediaDelivery(actor *model.User, logID string, options ResourceAccessOptions, rangeHeader string) (*ResourceDelivery, error) { - userID, resource, err := s.adminAPICallLogMediaResource(actor, logID) - if err != nil { - return nil, err - } - if options.Purpose == "" { - options.Purpose = assets.PurposeDisplay - } - return s.prepareResourceDelivery(userID, resource, options, rangeHeader) -} - -func (s *Service) adminAPICallLogMediaResource(actor *model.User, logID string) (string, *model.Resource, error) { - if err := s.RequireAdmin(actor); err != nil { - return "", nil, err - } - log, err := s.repo.APICallLog(strings.TrimSpace(logID)) - if err != nil { - return "", nil, err - } - if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") { - return "", nil, BadAuthRequest("该请求没有可预览媒体") - } - task, err := s.repo.Task(log.TaskID) - if err != nil { - return "", nil, err - } - if task.UserID != log.UserID { - return "", nil, BadAuthRequest("请求与媒体归属不一致") - } - previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type) - resourceID := canvasResourceID(previewURL) - if resourceID == "" { - return "", nil, BadAuthRequest("该请求没有已持久化媒体") - } - resource, err := s.repo.ResourceForUser(log.UserID, resourceID) - if err != nil { - return "", nil, err - } - return log.UserID, resource, nil -} - -func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - log, err := s.repo.APICallLog(strings.TrimSpace(id)) - if err != nil { - return nil, err - } - logs := []model.ApiCallLog{*log} - if err := s.decorateAPICallLogs(logs); err != nil { - return nil, err - } - return &logs[0], nil -} - -func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) ([]byte, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" { - return nil, BadAuthRequest("请求明细类型无效") - } - filter := normalizeAnalyticsFilter(query.AnalyticsQuery) - ids := uniqueNonEmpty(query.IDs) - if len(query.IDs) > 0 && len(ids) == 0 { - return nil, BadAuthRequest("请选择要导出的请求明细") - } - if len(ids) > 200 { - return nil, BadAuthRequest("单次最多导出 200 条已选请求明细") - } - logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000) - if err != nil { - return nil, err - } - if err := s.decorateAPICallLogs(logs); err != nil { - return nil, err - } - var buffer bytes.Buffer - buffer.WriteString("\xEF\xBB\xBF") - writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "销售价格(微积分)", "积分计费状态", "成本价格(微积分)", "上游估算费用(微单位)", "币种", "错误码", "错误"}) - for _, log := range logs { - startedAt := log.StartedAt - if startedAt.IsZero() { - startedAt = log.CreatedAt - } - billingAmount, billingStatus := "", "" - if log.BillingAvailable { - billingAmount = strconv.FormatInt(log.BillingAmount, 10) - billingStatus = string(log.BillingStatus) - } - upstreamCost := "" - creditCost := "" - if log.CreditCostMicrocredits != nil { - creditCost = strconv.FormatInt(*log.CreditCostMicrocredits, 10) - } - if log.CostAvailable { - upstreamCost = strconv.FormatInt(log.EstimatedCostMicros, 10) - } - _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, creditCost, upstreamCost, log.Currency, log.ErrorCode, log.Error}) - } - writer.Flush() - if err := writer.Error(); err != nil { - return nil, err - } - return buffer.Bytes(), nil -} - -func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([]byte, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - filter := normalizeAnalyticsFilter(query) - logs, err := s.repo.AnalyticsAPICallLogs(filter) - if err != nil { - return nil, err - } - records, err := s.analyticsFinancialRecords(logs) - if err != nil { - return nil, err - } - var buffer bytes.Buffer - buffer.WriteString("\xEF\xBB\xBF") - writer := csv.NewWriter(&buffer) - _ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "收入(微积分)", "成本(微积分)", "利润(微积分)", "错误类型"}) - for _, log := range logs { - revenue, cost, profit := "", "", "" - if record, exists := records[log.ID]; exists { - revenue = strconv.FormatInt(record.Revenue, 10) - if record.Cost != nil { - cost = strconv.FormatInt(*record.Cost, 10) - profit = strconv.FormatInt(record.Revenue-*record.Cost, 10) - } - } - inputTokens, outputTokens, cachedTokens := "", "", "" - if log.UsageAvailable { - inputTokens = strconv.FormatInt(log.InputTokens, 10) - outputTokens = strconv.FormatInt(log.OutputTokens, 10) - cachedTokens = strconv.FormatInt(log.CachedTokens, 10) - } - _ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), revenue, cost, profit, classifyAPICallError(log)}) - } - writer.Flush() - return buffer.Bytes(), writer.Error() -} - func (s *Service) AdminModelPricings(actor *model.User) ([]model.ModelPricing, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -533,677 +236,6 @@ func hasNegativePricing(req ModelPricingRequest) bool { return req.InputPerMillionMicros < 0 || req.OutputPerMillionMicros < 0 || req.CachedPerMillionMicros < 0 || req.PerRequestMicros < 0 || req.PerMediaMicros < 0 || req.PerVideoSecondMicros < 0 } -func normalizeAnalyticsFilter(query AnalyticsQuery) repository.AnalyticsFilter { - now := time.Now().UTC() - to := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) - from := to.AddDate(0, 0, -30) - if parsed, ok := parseAnalyticsTime(query.From); ok { - from = parsed - } - if parsed, ok := parseAnalyticsTime(query.To); ok { - to = parsed - if len(strings.TrimSpace(query.To)) == len("2006-01-02") { - to = to.AddDate(0, 0, 1) - } - } - if !to.After(from) { - to = from.AddDate(0, 0, 1) - } - if to.Sub(from) > 366*24*time.Hour { - from = to.AddDate(-1, 0, 0) - } - return repository.AnalyticsFilter{From: from, To: to, UserID: strings.TrimSpace(query.UserID), Model: strings.TrimSpace(query.Model), ChannelID: strings.TrimSpace(query.ChannelID), Capability: normalizeCapability(query.Capability)} -} - -func parseAnalyticsTime(value string) (time.Time, bool) { - value = strings.TrimSpace(value) - for _, layout := range []string{time.RFC3339, "2006-01-02"} { - if parsed, err := time.Parse(layout, value); err == nil { - return parsed.UTC(), true - } - } - return time.Time{}, false -} - -func normalizeCapability(value string) string { - switch strings.ToLower(strings.TrimSpace(value)) { - case "text", "image", "video", "audio": - return strings.ToLower(strings.TrimSpace(value)) - default: - return "" - } -} - -func buildAnalyticsOverview(filter repository.AnalyticsFilter, tasks []model.Task, rollingTasks []model.Task, rollingLogs []model.ApiCallLog, logs []model.ApiCallLog, activities []model.UserDailyActivity, users []model.User) *AnalyticsOverview { - result := &AnalyticsOverview{From: filter.From, To: filter.To, Trend: []AnalyticsTrendPoint{}, Models: []AnalyticsModelRow{}, Users: []AnalyticsUserRow{}, Failures: []AnalyticsFailureRow{}} - result.KPI.GenerationTasks = len(tasks) - result.KPI.UpstreamRequests = len(logs) - result.KPI.SuccessRate = successRateLogs(logs) - durations := make([]int64, 0, len(logs)) - activeUsers := map[string]bool{} - if !hasCreationDimensionFilter(filter) { - for _, activity := range activities { - if activity.Day.Before(filter.From) || !activity.Day.Before(filter.To) || !meaningfulActivity(activity) { - continue - } - activeUsers[activity.UserID] = true - } - } - for _, task := range tasks { - activeUsers[task.UserID] = true - } - for _, log := range logs { - activeUsers[log.UserID] = true - } - result.KPI.ActiveUsers = len(activeUsers) - rollingActivities := activities - if hasCreationDimensionFilter(filter) { - rollingActivities = nil - } - result.KPI.DAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -1), filter.To) - result.KPI.WAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -7), filter.To) - result.KPI.MAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -30), filter.To) - for _, log := range logs { - durations = append(durations, log.DurationMs) - } - result.KPI.P95DurationMs = percentile(durations, 0.95) - result.Trend = buildAnalyticsTrend(filter, tasks, logs, activities) - result.Models = buildAnalyticsModels(tasks, logs) - result.Users = buildAnalyticsUsers(filter, tasks, logs, activities, users) - result.Failures = buildAnalyticsFailures(logs) - return result -} - -func buildAnalyticsTrend(filter repository.AnalyticsFilter, tasks []model.Task, logs []model.ApiCallLog, activities []model.UserDailyActivity) []AnalyticsTrendPoint { - points := map[string]*AnalyticsTrendPoint{} - for day := time.Date(filter.From.Year(), filter.From.Month(), filter.From.Day(), 0, 0, 0, 0, time.UTC); day.Before(filter.To); day = day.AddDate(0, 0, 1) { - key := day.Format("2006-01-02") - points[key] = &AnalyticsTrendPoint{Day: key} - } - requestTotals := map[string]int{} - requestSuccess := map[string]int{} - activeByDay := map[string]map[string]bool{} - for _, task := range tasks { - key := task.CreatedAt.UTC().Format("2006-01-02") - if point := points[key]; point != nil { - point.Tasks++ - if activeByDay[key] == nil { - activeByDay[key] = map[string]bool{} - } - activeByDay[key][task.UserID] = true - } - } - for _, log := range logs { - key := log.CreatedAt.UTC().Format("2006-01-02") - if point := points[key]; point != nil { - point.Requests++ - requestTotals[key]++ - if log.Status == model.ApiCallStatusSucceeded { - requestSuccess[key]++ - } - if activeByDay[key] == nil { - activeByDay[key] = map[string]bool{} - } - activeByDay[key][log.UserID] = true - } - } - if !hasCreationDimensionFilter(filter) { - for _, activity := range activities { - key := activity.Day.UTC().Format("2006-01-02") - if points[key] == nil || !meaningfulActivity(activity) { - continue - } - if activeByDay[key] == nil { - activeByDay[key] = map[string]bool{} - } - activeByDay[key][activity.UserID] = true - } - } - keys := make([]string, 0, len(points)) - for key := range points { - keys = append(keys, key) - } - sort.Strings(keys) - result := make([]AnalyticsTrendPoint, 0, len(keys)) - for _, key := range keys { - point := points[key] - point.ActiveUsers = len(activeByDay[key]) - point.RequestSuccessRate = ratio(requestSuccess[key], requestTotals[key]) - result = append(result, *point) - } - return result -} - -func buildAnalyticsModels(tasks []model.Task, logs []model.ApiCallLog) []AnalyticsModelRow { - type accumulator struct { - row AnalyticsModelRow - users map[string]bool - taskSuccess int - taskTotal int - requestSuccess int - durations []int64 - } - items := map[string]*accumulator{} - get := func(modelName string, capability string) *accumulator { - if modelName == "" { - modelName = "未识别" - } - key := modelName + "\x00" + capability - if items[key] == nil { - items[key] = &accumulator{row: AnalyticsModelRow{Model: modelName, Capability: capability}, users: map[string]bool{}} - } - return items[key] - } - for _, task := range tasks { - capability := capabilityFromTaskType(task.Type) - item := get(task.Model, capability) - item.row.Tasks++ - item.users[task.UserID] = true - if task.Status != model.TaskStatusCancelled { - item.taskTotal++ - if task.Status == model.TaskStatusSucceeded { - item.taskSuccess++ - } - } - } - for _, log := range logs { - item := get(log.Model, log.Capability) - item.row.Requests++ - item.users[log.UserID] = true - item.durations = append(item.durations, log.DurationMs) - if log.Status == model.ApiCallStatusSucceeded { - item.requestSuccess++ - } - if log.UsageAvailable { - item.row.UsageAvailable = true - item.row.InputTokens += log.InputTokens - item.row.OutputTokens += log.OutputTokens - item.row.CachedTokens += log.CachedTokens - } - item.row.MediaCount += log.MediaCount - item.row.VideoSeconds += log.VideoSeconds - } - result := make([]AnalyticsModelRow, 0, len(items)) - for _, item := range items { - item.row.UniqueUsers = len(item.users) - item.row.TaskSuccessRate = ratio(item.taskSuccess, item.taskTotal) - item.row.RequestSuccessRate = ratio(item.requestSuccess, item.row.Requests) - item.row.P50DurationMs = percentile(item.durations, 0.5) - item.row.P95DurationMs = percentile(item.durations, 0.95) - result = append(result, item.row) - } - sort.Slice(result, func(i, j int) bool { - if result[i].Tasks == result[j].Tasks { - return result[i].Requests > result[j].Requests - } - return result[i].Tasks > result[j].Tasks - }) - return result -} - -func buildAnalyticsUsers(filter repository.AnalyticsFilter, tasks []model.Task, logs []model.ApiCallLog, activities []model.UserDailyActivity, users []model.User) []AnalyticsUserRow { - names := map[string]string{} - for _, user := range users { - names[user.ID] = firstNonEmpty(user.DisplayName, user.Username) - } - rows := map[string]*AnalyticsUserRow{} - models := map[string]map[string]int{} - get := func(userID string) *AnalyticsUserRow { - if rows[userID] == nil { - rows[userID] = &AnalyticsUserRow{UserID: userID, Name: firstNonEmpty(names[userID], userID)} - } - return rows[userID] - } - if !hasCreationDimensionFilter(filter) { - for _, activity := range activities { - if activity.Day.Before(filter.From) || !activity.Day.Before(filter.To) || !meaningfulActivity(activity) { - continue - } - row := get(activity.UserID) - row.ActiveDays++ - row.AgentMessages += activity.AgentMessageCount - if activity.CanvasActive { - row.CanvasDays++ - } - row.Assets += activity.AssetCount - row.Resources += activity.ResourceCount - } - } - for _, task := range tasks { - if models[task.UserID] == nil { - models[task.UserID] = map[string]int{} - } - models[task.UserID][task.Model]++ - get(task.UserID).Tasks++ - } - if hasCreationDimensionFilter(filter) { - activeDays := map[string]map[string]bool{} - for _, log := range logs { - get(log.UserID) - if activeDays[log.UserID] == nil { - activeDays[log.UserID] = map[string]bool{} - } - activeDays[log.UserID][log.CreatedAt.UTC().Format("2006-01-02")] = true - } - for userID, days := range activeDays { - get(userID).ActiveDays = len(days) - } - } - result := make([]AnalyticsUserRow, 0, len(rows)) - for userID, row := range rows { - bestCount := 0 - for modelName, count := range models[userID] { - if modelName != "" && count > bestCount { - row.CommonModel, bestCount = modelName, count - } - } - result = append(result, *row) - } - sort.Slice(result, func(i, j int) bool { - if result[i].Tasks == result[j].Tasks { - return result[i].ActiveDays > result[j].ActiveDays - } - return result[i].Tasks > result[j].Tasks - }) - return result -} - -func buildAnalyticsFailures(logs []model.ApiCallLog) []AnalyticsFailureRow { - items := map[string]*AnalyticsFailureRow{} - for _, log := range logs { - if log.Status != model.ApiCallStatusFailed { - continue - } - typeName := classifyAPICallError(log) - modelName := firstNonEmpty(log.Model, "未识别") - key := typeName + "\x00" + modelName - if items[key] == nil { - items[key] = &AnalyticsFailureRow{Type: typeName, Model: modelName} - } - item := items[key] - item.Count++ - if log.CreatedAt.After(item.LastSeenAt) { - item.LastSeenAt = log.CreatedAt - item.LastError = truncateRunes(log.Error, 180) - } - } - result := make([]AnalyticsFailureRow, 0, len(items)) - for _, item := range items { - result = append(result, *item) - } - sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count }) - return result -} - -func classifyAPICallError(log model.ApiCallLog) string { - value := strings.ToLower(log.Error) - switch { - case log.ErrorCode == contentModerationErrorCode: - return "内容审核" - case strings.Contains(value, "timeout"), strings.Contains(value, "超时"), log.StatusCode == 408, log.StatusCode == 504, log.StatusCode == 524: - return "超时" - case log.StatusCode == 401 || log.StatusCode == 403 || strings.Contains(value, "unauthorized"): - return "鉴权失败" - case log.StatusCode == 429 || strings.Contains(value, "rate limit"): - return "限流" - case log.StatusCode >= 400 && log.StatusCode < 500: - return "请求参数" - case log.StatusCode >= 500: - return "上游服务" - case value != "": - return "网络或客户端" - default: - return "未知错误" - } -} - -func meaningfulActivity(activity model.UserDailyActivity) bool { - return activity.TaskCount > 0 || activity.AgentMessageCount > 0 || activity.CanvasActive || activity.AssetCount > 0 || activity.ResourceCount > 0 -} - -func rollingActiveUsers(activities []model.UserDailyActivity, tasks []model.Task, logs []model.ApiCallLog, from time.Time, to time.Time) int { - users := map[string]bool{} - for _, activity := range activities { - if !activity.Day.Before(from) && activity.Day.Before(to) && meaningfulActivity(activity) { - users[activity.UserID] = true - } - } - for _, task := range tasks { - if !task.CreatedAt.Before(from) && task.CreatedAt.Before(to) { - users[task.UserID] = true - } - } - for _, log := range logs { - if !log.CreatedAt.Before(from) && log.CreatedAt.Before(to) { - users[log.UserID] = true - } - } - return len(users) -} - -func successRateLogs(logs []model.ApiCallLog) float64 { - succeeded, failed := 0, 0 - for _, log := range logs { - if log.Status == model.ApiCallStatusSucceeded { - succeeded++ - } else if log.Status == model.ApiCallStatusFailed { - failed++ - } - } - return ratio(succeeded, succeeded+failed) -} - -func ratio(value int, total int) float64 { - if total == 0 { - return 0 - } - return float64(value) * 100 / float64(total) -} - -func percentile(values []int64, quantile float64) int64 { - if len(values) == 0 { - return 0 - } - items := append([]int64(nil), values...) - sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) - index := int(float64(len(items)-1)*quantile + 0.5) - return items[index] -} - -func capabilityFromTaskType(taskType string) string { - value := strings.ToLower(taskType) - for _, capability := range []string{"video", "image", "audio", "text"} { - if strings.Contains(value, capability) { - return capability - } - } - if strings.Contains(value, "storyboard") || strings.Contains(value, "agent") { - return "text" - } - return "" -} - -func hasCreationDimensionFilter(filter repository.AnalyticsFilter) bool { - return filter.Model != "" || filter.ChannelID != "" || filter.Capability != "" -} - -func tasksWithLoggedRequests(tasks []model.Task, logs []model.ApiCallLog) []model.Task { - ids := map[string]bool{} - for _, log := range logs { - if log.TaskID != "" { - ids[log.TaskID] = true - } - } - result := make([]model.Task, 0, len(tasks)) - for _, task := range tasks { - if ids[task.ID] { - result = append(result, task) - } - } - return result -} - -func (s *Service) estimateCallCost(log *model.ApiCallLog) { - if log.Status == model.ApiCallStatusFailed && !log.UsageAvailable { - return - } - pricing, err := s.repo.ModelPricing(log.ChannelID, log.Model, log.Capability) - if err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return - } - return - } - cost := int64(0) - if log.Billable { - cost = pricing.PerRequestMicros - } - cost += log.InputTokens * pricing.InputPerMillionMicros / 1_000_000 - cost += log.OutputTokens * pricing.OutputPerMillionMicros / 1_000_000 - cost += log.CachedTokens * pricing.CachedPerMillionMicros / 1_000_000 - cost += int64(log.MediaCount) * pricing.PerMediaMicros - cost += int64(log.VideoSeconds) * pricing.PerVideoSecondMicros - log.EstimatedCostMicros = cost - log.CostAvailable = true - log.Currency = pricing.Currency -} - -func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { - if log == nil { - return - } - if log.ProviderRequestID == "" { - log.ProviderRequestID = providerRequestIDFromPath(log.Path) - } - payloads := providerResponsePayloads(responseBody) - for _, payload := range payloads { - s.enrichAPICallLogPayload(log, payload) - } - s.enrichAPICallLogFailureSummary(log, responseBody) -} - -func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, responseBody []byte) { - if log.Status != model.ApiCallStatusFailed || log.StatusCode < 400 { - return - } - userMessage := providerUserFacingErrorMessage(providerHTTPError{ - StatusCode: log.StatusCode, - Body: string(responseBody), - }) - detail := strings.TrimSpace(log.Error) - if detail == "" || detail == userMessage || strings.Contains(userMessage, ";上游:"+detail) { - log.Error = userMessage - return - } - if strings.Contains(detail, userMessage) { - return - } - log.Error = truncateRunes(userMessage+";上游:"+detail, 2_000) -} - -func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { - nestedTaskID := "" - if data, ok := payload["data"].(map[string]any); ok { - if log.Capability == "video" && strings.Contains(log.Path, "/v1/video/generations") { - if extracted, err := firstJSONString(data, "task_id", "taskId"); err == nil { - nestedTaskID = extracted - } - } - for key, value := range data { - if _, exists := payload[key]; !exists { - payload[key] = value - } - } - } - // Responses API 的终态 SSE 把实际响应(包括 usage)放在 response 字段中。 - if response, ok := payload["response"].(map[string]any); ok { - for key, value := range response { - if _, exists := payload[key]; !exists { - payload[key] = value - } - } - } - if log.Status == model.ApiCallStatusFailed { - errorCode, errorMessage := providerFailureDetails(payload) - log.ErrorCode = errorCode - if errorMessage != "" { - log.Error = errorMessage - } - } - arkVideo := log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") - usage, _ := payload["usage"].(map[string]any) - if log.Capability == "video" { - log.InputTokens, log.CachedTokens = 0, 0 - log.OutputTokens, log.UsageAvailable = videoCompletionTokens(payload, arkVideo) - } else if usage != nil { - inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens") - outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens") - if inputAvailable { - log.InputTokens = inputTokens - } - if outputAvailable { - log.OutputTokens = outputTokens - } - if inputAvailable || outputAvailable { - log.UsageAvailable = true - } - if details, ok := usage["input_tokens_details"].(map[string]any); ok { - log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens") - } - if details, ok := usage["prompt_tokens_details"].(map[string]any); ok && log.CachedTokens == 0 { - log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens") - } - if log.CachedTokens == 0 { - log.CachedTokens = firstInt64(usage, "cached_tokens", "cache_read_input_tokens", "prompt_cache_hit_tokens") - } - } - if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok && log.Capability != "video" { - inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount") - outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount") - if inputAvailable { - log.InputTokens = inputTokens - } - if outputAvailable { - log.OutputTokens = outputTokens - } - if inputAvailable || outputAvailable { - log.UsageAvailable = true - } - log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount") - } - if extracted, err := firstJSONString(payload, "task_id", "id", "request_id", "name"); err == nil { - log.ProviderRequestID = firstNonEmpty(nestedTaskID, extracted, log.ProviderRequestID) - } else { - log.ProviderRequestID = firstNonEmpty(nestedTaskID, log.ProviderRequestID) - } - log.ProviderStatus = strings.ToLower(firstNonEmpty(stringField(payload, "status"), log.ProviderStatus)) - if log.ProviderStatus == "failed" || log.ProviderStatus == "cancelled" || log.ProviderStatus == "expired" { - log.Status = model.ApiCallStatusFailed - errorCode, errorMessage := providerFailureDetails(payload) - log.ErrorCode = firstNonEmpty(errorCode, log.ErrorCode) - log.Error = firstNonEmpty(errorMessage, log.Error) - } - if log.Capability == "image" { - if data, ok := payload["data"].([]any); ok { - log.MediaCount = len(data) - } else if images, ok := payload["images"].([]any); ok { - log.MediaCount = len(images) - } - } -} - -func videoCompletionTokens(payload map[string]any, arkProtocol bool) (int64, bool) { - if status, exists := payload["status"]; exists { - text, ok := status.(string) - status := strings.ToLower(strings.TrimSpace(text)) - if !ok || (status != "succeeded" && (arkProtocol || (status != "completed" && status != "success"))) { - return 0, false - } - } - usage, _ := payload["usage"].(map[string]any) - value, exists := usage["completion_tokens"] - if !exists && !arkProtocol { - value, exists = usage["output_tokens"] - } - if !exists { - value = usage["total_tokens"] - } - // completion_tokens 一旦返回就是唯一结算依据,非法值不能用 total_tokens 掩盖。 - // JSON Number 保留整数精度,避免浮点取整把小数或超出 int64 的值变成可计费用量。 - number, ok := value.(json.Number) - if !ok { - return 0, false - } - tokens, err := number.Int64() - if err != nil || tokens <= 0 { - return 0, false - } - return tokens, true -} - -func providerResponsePayloads(responseBody []byte) []map[string]any { - if len(responseBody) == 0 { - return nil - } - if payload, ok := decodeProviderResponsePayload(responseBody); ok { - return []map[string]any{payload} - } - - // 流式文本的用量只出现在最后一个 SSE data 事件中,不能把整段响应当作 JSON。 - result := make([]map[string]any, 0) - scanner := bufio.NewScanner(bytes.NewReader(responseBody)) - scanner.Buffer(make([]byte, 64<<10), max(len(responseBody)+1, 64<<10)) - dataLines := make([]string, 0, 1) - flush := func() { - raw := strings.TrimSpace(strings.Join(dataLines, "\n")) - dataLines = dataLines[:0] - if raw == "" || raw == "[DONE]" { - return - } - if event, ok := decodeProviderResponsePayload([]byte(raw)); ok { - result = append(result, event) - } - } - for scanner.Scan() { - line := strings.TrimSuffix(scanner.Text(), "\r") - if line == "" { - flush() - continue - } - if strings.HasPrefix(line, "data:") { - dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) - } - } - flush() - return result -} - -func decodeProviderResponsePayload(data []byte) (map[string]any, bool) { - if !json.Valid(data) { - return nil, false - } - decoder := json.NewDecoder(bytes.NewReader(data)) - decoder.UseNumber() - var payload map[string]any - err := decoder.Decode(&payload) - return payload, err == nil && payload != nil -} - -func providerRequestIDFromPath(path string) string { - parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/") - for index := len(parts) - 1; index >= 0; index-- { - part := strings.TrimSpace(parts[index]) - if part == "" || part == "content" || part == "download" { - continue - } - if index > 0 && (parts[index-1] == "videos" || parts[index-1] == "tasks") { - return part - } - break - } - return "" -} - -func firstInt64(values map[string]any, keys ...string) int64 { - value, _ := firstInt64Value(values, keys...) - return value -} - -func firstInt64Value(values map[string]any, keys ...string) (int64, bool) { - for _, key := range keys { - switch value := values[key].(type) { - case float64: - return int64(value), true - case int64: - return value, true - case json.Number: - parsed, err := value.Int64() - return parsed, err == nil - } - } - return 0, false -} - func (s *Service) recordActivity(userID string, event string, count int) { if err := s.repo.RecordUserActivity(userID, event, count, time.Now()); err != nil { log.Printf("record user activity failed: event=%s count=%d error=%v", event, count, err) diff --git a/backend/internal/app/analytics_api_logs.go b/backend/internal/app/analytics_api_logs.go new file mode 100644 index 000000000..bdbd919b7 --- /dev/null +++ b/backend/internal/app/analytics_api_logs.go @@ -0,0 +1,303 @@ +// 管理后台的 API 调用日志:分页、详情、CSV 导出、媒体预览与上游响应摘要补全。 +// +// 日志只保存脱敏后的请求摘要;上游响应体中的媒体通过资源访问合同读取,不暴露原始地址。 + +package app + +import ( + "bytes" + "encoding/csv" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/assets" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func (s *Service) AdminAPICallLogs(actor *model.User, query APICallLogQuery) (*APICallLogPage, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" { + return nil, BadAuthRequest("请求明细类型无效") + } + filter := normalizeAnalyticsFilter(query.AnalyticsQuery) + logs, total, err := s.repo.QueryAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, Page: query.Page, Limit: query.Limit}) + if err != nil { + return nil, err + } + if err := s.decorateAPICallLogs(logs); err != nil { + return nil, err + } + for index := range logs { + // 原始报文只允许通过详情接口按单条读取。 + logs[index].RequestBody = "" + logs[index].ResponseBody = "" + } + page, limit := query.Page, query.Limit + if page <= 0 { + page = 1 + } + if limit <= 0 || limit > 200 { + limit = 50 + } + return &APICallLogPage{Logs: logs, Total: total, Page: page, Limit: limit}, nil +} + +func (s *Service) decorateAPICallLogs(logs []model.ApiCallLog) error { + // 历史日志允许读取逻辑删除渠道的名称,但不读取或返回渠道密钥。 + channels, err := s.repo.HistoricalSystemChannelReferences() + if err != nil { + return err + } + channelNames := make(map[string]string, len(channels)) + for _, channel := range channels { + channelNames[channel.ID] = channel.Name + } + users, err := s.repo.Users() + if err != nil { + return err + } + userByID := make(map[string]model.User, len(users)) + for _, user := range users { + userByID[user.ID] = user + } + billingOrderIDs := make([]string, 0, len(logs)) + seenBillingOrderIDs := make(map[string]struct{}, len(logs)) + taskIDs := make([]string, 0, len(logs)) + seenTaskIDs := make(map[string]struct{}, len(logs)) + for _, log := range logs { + if log.Billable && log.BillingOrderID != "" { + if _, exists := seenBillingOrderIDs[log.BillingOrderID]; !exists { + seenBillingOrderIDs[log.BillingOrderID] = struct{}{} + billingOrderIDs = append(billingOrderIDs, log.BillingOrderID) + } + } + if (log.Capability != "image" && log.Capability != "video" && log.Capability != "audio") || log.TaskID == "" { + continue + } + if _, exists := seenTaskIDs[log.TaskID]; exists { + continue + } + seenTaskIDs[log.TaskID] = struct{}{} + taskIDs = append(taskIDs, log.TaskID) + } + tasks, err := s.repo.APICallLogTasks(taskIDs) + if err != nil { + return err + } + taskByID := make(map[string]model.Task, len(tasks)) + for _, task := range tasks { + taskByID[task.ID] = task + } + billingOrderByID, err := s.repo.BillingOrdersByIDs(billingOrderIDs) + if err != nil { + return err + } + for index := range logs { + if logs[index].StartedAt.IsZero() { + logs[index].StartedAt = logs[index].CreatedAt + } + if logs[index].ChannelID == "" { + logs[index].ChannelName = "自定义渠道" + } else if name := channelNames[logs[index].ChannelID]; name != "" { + logs[index].ChannelName = name + } else { + logs[index].ChannelName = "已删除渠道" + } + if user, exists := userByID[logs[index].UserID]; exists { + logs[index].UserDisplayName = user.DisplayName + logs[index].UserAccount = user.Username + } + if logs[index].Billable { + if order, exists := billingOrderByID[logs[index].BillingOrderID]; exists && order.UserID == logs[index].UserID { + logs[index].BillingAvailable = true + logs[index].BillingStatus = order.Status + if order.ChannelID == logs[index].ChannelID { + logs[index].CreditCostConfigured = order.CostPricing.Configured + cost, costErr := billingCreditCost(order) + if costErr != nil { + return costErr + } + logs[index].CreditCostMicrocredits = cost + } + if order.Status == model.BillingStatusSettled { + logs[index].BillingAmount = order.ActualAmountMicrocredits + } else if order.Status != model.BillingStatusRefunded { + logs[index].BillingAmount = order.ReservedAmountMicrocredits + } + } + } + if task, exists := taskByID[logs[index].TaskID]; exists && task.UserID == logs[index].UserID { + logs[index].TaskStatus = task.Status + logs[index].MediaStage = task.MediaStage + previewURL, previewKind := taskMediaPreview(task.ResultJSON, task.Type) + if canvasResourceID(previewURL) != "" { + logs[index].MediaPreviewURL = "/api/admin/api-logs/" + logs[index].ID + "/media" + logs[index].MediaPreviewKind = previewKind + } else if strings.HasPrefix(previewURL, "https://") || strings.HasPrefix(previewURL, "http://") { + logs[index].MediaPreviewURL = previewURL + logs[index].MediaPreviewKind = previewKind + } + } + } + return nil +} + +// 管理员媒体读取必须同时校验日志、任务和资源归属,不能绕过用户资源边界按资源 ID 任意读取。 +func (s *Service) OpenAdminAPICallLogMediaRange(actor *model.User, logID string, rangeHeader string) (*ResourceStream, error) { + userID, resource, err := s.adminAPICallLogMediaResource(actor, logID) + if err != nil { + return nil, err + } + return s.openResourceRange(userID, resource, rangeHeader) +} + +func (s *Service) PrepareAdminAPICallLogMediaDelivery(actor *model.User, logID string, options ResourceAccessOptions, rangeHeader string) (*ResourceDelivery, error) { + userID, resource, err := s.adminAPICallLogMediaResource(actor, logID) + if err != nil { + return nil, err + } + if options.Purpose == "" { + options.Purpose = assets.PurposeDisplay + } + return s.prepareResourceDelivery(userID, resource, options, rangeHeader) +} + +func (s *Service) adminAPICallLogMediaResource(actor *model.User, logID string) (string, *model.Resource, error) { + if err := s.RequireAdmin(actor); err != nil { + return "", nil, err + } + log, err := s.repo.APICallLog(strings.TrimSpace(logID)) + if err != nil { + return "", nil, err + } + if log.TaskID == "" || (log.Capability != "image" && log.Capability != "video") { + return "", nil, BadAuthRequest("该请求没有可预览媒体") + } + task, err := s.repo.Task(log.TaskID) + if err != nil { + return "", nil, err + } + if task.UserID != log.UserID { + return "", nil, BadAuthRequest("请求与媒体归属不一致") + } + previewURL, _ := taskMediaPreview(task.ResultJSON, task.Type) + resourceID := canvasResourceID(previewURL) + if resourceID == "" { + return "", nil, BadAuthRequest("该请求没有已持久化媒体") + } + resource, err := s.repo.ResourceForUser(log.UserID, resourceID) + if err != nil { + return "", nil, err + } + return log.UserID, resource, nil +} + +func (s *Service) AdminAPICallLog(actor *model.User, id string) (*model.ApiCallLog, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + log, err := s.repo.APICallLog(strings.TrimSpace(id)) + if err != nil { + return nil, err + } + logs := []model.ApiCallLog{*log} + if err := s.decorateAPICallLogs(logs); err != nil { + return nil, err + } + return &logs[0], nil +} + +func (s *Service) AdminAPICallLogsCSV(actor *model.User, query APICallLogQuery) ([]byte, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + if query.RecordType != "" && query.RecordType != "request" && query.RecordType != "download" && query.RecordType != "all" { + return nil, BadAuthRequest("请求明细类型无效") + } + filter := normalizeAnalyticsFilter(query.AnalyticsQuery) + ids := uniqueNonEmpty(query.IDs) + if len(query.IDs) > 0 && len(ids) == 0 { + return nil, BadAuthRequest("请选择要导出的请求明细") + } + if len(ids) > 200 { + return nil, BadAuthRequest("单次最多导出 200 条已选请求明细") + } + logs, err := s.repo.ExportAPICallLogs(repository.APICallLogFilter{AnalyticsFilter: filter, RecordType: query.RecordType, Keyword: query.Keyword, Status: query.Status, IDs: ids}, 10_000) + if err != nil { + return nil, err + } + if err := s.decorateAPICallLogs(logs); err != nil { + return nil, err + } + var buffer bytes.Buffer + buffer.WriteString("\xEF\xBB\xBF") + writer := csv.NewWriter(&buffer) + _ = writer.Write([]string{"时间", "用户", "用户账号", "渠道", "模型", "能力", "状态", "轮询次数", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "销售价格(微积分)", "积分计费状态", "成本价格(微积分)", "上游估算费用(微单位)", "币种", "错误码", "错误"}) + for _, log := range logs { + startedAt := log.StartedAt + if startedAt.IsZero() { + startedAt = log.CreatedAt + } + billingAmount, billingStatus := "", "" + if log.BillingAvailable { + billingAmount = strconv.FormatInt(log.BillingAmount, 10) + billingStatus = string(log.BillingStatus) + } + upstreamCost := "" + creditCost := "" + if log.CreditCostMicrocredits != nil { + creditCost = strconv.FormatInt(*log.CreditCostMicrocredits, 10) + } + if log.CostAvailable { + upstreamCost = strconv.FormatInt(log.EstimatedCostMicros, 10) + } + _ = writer.Write([]string{startedAt.UTC().Format(time.RFC3339), log.UserDisplayName, log.UserAccount, log.ChannelName, log.Model, log.Capability, string(log.Status), strconv.Itoa(log.PollCount), strconv.FormatInt(log.DurationMs, 10), strconv.FormatInt(log.InputTokens, 10), strconv.FormatInt(log.OutputTokens, 10), strconv.FormatInt(log.CachedTokens, 10), billingAmount, billingStatus, creditCost, upstreamCost, log.Currency, log.ErrorCode, log.Error}) + } + writer.Flush() + if err := writer.Error(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func (s *Service) AdminAnalyticsCSV(actor *model.User, query AnalyticsQuery) ([]byte, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + filter := normalizeAnalyticsFilter(query) + logs, err := s.repo.AnalyticsAPICallLogs(filter) + if err != nil { + return nil, err + } + records, err := s.analyticsFinancialRecords(logs) + if err != nil { + return nil, err + } + var buffer bytes.Buffer + buffer.WriteString("\xEF\xBB\xBF") + writer := csv.NewWriter(&buffer) + _ = writer.Write([]string{"时间", "用户ID", "渠道ID", "任务ID", "能力", "请求阶段", "模型", "状态", "状态码", "耗时毫秒", "输入Token", "输出Token", "缓存Token", "媒体数量", "视频秒数", "收入(微积分)", "成本(微积分)", "利润(微积分)", "错误类型"}) + for _, log := range logs { + revenue, cost, profit := "", "", "" + if record, exists := records[log.ID]; exists { + revenue = strconv.FormatInt(record.Revenue, 10) + if record.Cost != nil { + cost = strconv.FormatInt(*record.Cost, 10) + profit = strconv.FormatInt(record.Revenue-*record.Cost, 10) + } + } + inputTokens, outputTokens, cachedTokens := "", "", "" + if log.UsageAvailable { + inputTokens = strconv.FormatInt(log.InputTokens, 10) + outputTokens = strconv.FormatInt(log.OutputTokens, 10) + cachedTokens = strconv.FormatInt(log.CachedTokens, 10) + } + _ = writer.Write([]string{log.CreatedAt.Format(time.RFC3339), log.UserID, log.ChannelID, log.TaskID, log.Capability, log.RequestKind, log.Model, string(log.Status), strconv.Itoa(log.StatusCode), strconv.FormatInt(log.DurationMs, 10), inputTokens, outputTokens, cachedTokens, strconv.Itoa(log.MediaCount), strconv.Itoa(log.VideoSeconds), revenue, cost, profit, classifyAPICallError(log)}) + } + writer.Flush() + return buffer.Bytes(), writer.Error() +} diff --git a/backend/internal/app/analytics_build.go b/backend/internal/app/analytics_build.go new file mode 100644 index 000000000..ba8c83a2e --- /dev/null +++ b/backend/internal/app/analytics_build.go @@ -0,0 +1,424 @@ +// 运营统计的聚合计算:概览 KPI、趋势、模型与用户排行、失败归类。 +// +// 全部是对已查询出的任务与调用日志做内存聚合的纯函数,便于单测。 + +package app + +import ( + "sort" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func normalizeAnalyticsFilter(query AnalyticsQuery) repository.AnalyticsFilter { + now := time.Now().UTC() + to := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC) + from := to.AddDate(0, 0, -30) + if parsed, ok := parseAnalyticsTime(query.From); ok { + from = parsed + } + if parsed, ok := parseAnalyticsTime(query.To); ok { + to = parsed + if len(strings.TrimSpace(query.To)) == len("2006-01-02") { + to = to.AddDate(0, 0, 1) + } + } + if !to.After(from) { + to = from.AddDate(0, 0, 1) + } + if to.Sub(from) > 366*24*time.Hour { + from = to.AddDate(-1, 0, 0) + } + return repository.AnalyticsFilter{From: from, To: to, UserID: strings.TrimSpace(query.UserID), Model: strings.TrimSpace(query.Model), ChannelID: strings.TrimSpace(query.ChannelID), Capability: normalizeCapability(query.Capability)} +} + +func parseAnalyticsTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + for _, layout := range []string{time.RFC3339, "2006-01-02"} { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed.UTC(), true + } + } + return time.Time{}, false +} + +func normalizeCapability(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "text", "image", "video", "audio": + return strings.ToLower(strings.TrimSpace(value)) + default: + return "" + } +} + +func buildAnalyticsOverview(filter repository.AnalyticsFilter, tasks []model.Task, rollingTasks []model.Task, rollingLogs []model.ApiCallLog, logs []model.ApiCallLog, activities []model.UserDailyActivity, users []model.User) *AnalyticsOverview { + result := &AnalyticsOverview{From: filter.From, To: filter.To, Trend: []AnalyticsTrendPoint{}, Models: []AnalyticsModelRow{}, Users: []AnalyticsUserRow{}, Failures: []AnalyticsFailureRow{}} + result.KPI.GenerationTasks = len(tasks) + result.KPI.UpstreamRequests = len(logs) + result.KPI.SuccessRate = successRateLogs(logs) + durations := make([]int64, 0, len(logs)) + activeUsers := map[string]bool{} + if !hasCreationDimensionFilter(filter) { + for _, activity := range activities { + if activity.Day.Before(filter.From) || !activity.Day.Before(filter.To) || !meaningfulActivity(activity) { + continue + } + activeUsers[activity.UserID] = true + } + } + for _, task := range tasks { + activeUsers[task.UserID] = true + } + for _, log := range logs { + activeUsers[log.UserID] = true + } + result.KPI.ActiveUsers = len(activeUsers) + rollingActivities := activities + if hasCreationDimensionFilter(filter) { + rollingActivities = nil + } + result.KPI.DAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -1), filter.To) + result.KPI.WAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -7), filter.To) + result.KPI.MAU = rollingActiveUsers(rollingActivities, rollingTasks, rollingLogs, filter.To.AddDate(0, 0, -30), filter.To) + for _, log := range logs { + durations = append(durations, log.DurationMs) + } + result.KPI.P95DurationMs = percentile(durations, 0.95) + result.Trend = buildAnalyticsTrend(filter, tasks, logs, activities) + result.Models = buildAnalyticsModels(tasks, logs) + result.Users = buildAnalyticsUsers(filter, tasks, logs, activities, users) + result.Failures = buildAnalyticsFailures(logs) + return result +} + +func buildAnalyticsTrend(filter repository.AnalyticsFilter, tasks []model.Task, logs []model.ApiCallLog, activities []model.UserDailyActivity) []AnalyticsTrendPoint { + points := map[string]*AnalyticsTrendPoint{} + for day := time.Date(filter.From.Year(), filter.From.Month(), filter.From.Day(), 0, 0, 0, 0, time.UTC); day.Before(filter.To); day = day.AddDate(0, 0, 1) { + key := day.Format("2006-01-02") + points[key] = &AnalyticsTrendPoint{Day: key} + } + requestTotals := map[string]int{} + requestSuccess := map[string]int{} + activeByDay := map[string]map[string]bool{} + for _, task := range tasks { + key := task.CreatedAt.UTC().Format("2006-01-02") + if point := points[key]; point != nil { + point.Tasks++ + if activeByDay[key] == nil { + activeByDay[key] = map[string]bool{} + } + activeByDay[key][task.UserID] = true + } + } + for _, log := range logs { + key := log.CreatedAt.UTC().Format("2006-01-02") + if point := points[key]; point != nil { + point.Requests++ + requestTotals[key]++ + if log.Status == model.ApiCallStatusSucceeded { + requestSuccess[key]++ + } + if activeByDay[key] == nil { + activeByDay[key] = map[string]bool{} + } + activeByDay[key][log.UserID] = true + } + } + if !hasCreationDimensionFilter(filter) { + for _, activity := range activities { + key := activity.Day.UTC().Format("2006-01-02") + if points[key] == nil || !meaningfulActivity(activity) { + continue + } + if activeByDay[key] == nil { + activeByDay[key] = map[string]bool{} + } + activeByDay[key][activity.UserID] = true + } + } + keys := make([]string, 0, len(points)) + for key := range points { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]AnalyticsTrendPoint, 0, len(keys)) + for _, key := range keys { + point := points[key] + point.ActiveUsers = len(activeByDay[key]) + point.RequestSuccessRate = ratio(requestSuccess[key], requestTotals[key]) + result = append(result, *point) + } + return result +} + +func buildAnalyticsModels(tasks []model.Task, logs []model.ApiCallLog) []AnalyticsModelRow { + type accumulator struct { + row AnalyticsModelRow + users map[string]bool + taskSuccess int + taskTotal int + requestSuccess int + durations []int64 + } + items := map[string]*accumulator{} + get := func(modelName string, capability string) *accumulator { + if modelName == "" { + modelName = "未识别" + } + key := modelName + "\x00" + capability + if items[key] == nil { + items[key] = &accumulator{row: AnalyticsModelRow{Model: modelName, Capability: capability}, users: map[string]bool{}} + } + return items[key] + } + for _, task := range tasks { + capability := capabilityFromTaskType(task.Type) + item := get(task.Model, capability) + item.row.Tasks++ + item.users[task.UserID] = true + if task.Status != model.TaskStatusCancelled { + item.taskTotal++ + if task.Status == model.TaskStatusSucceeded { + item.taskSuccess++ + } + } + } + for _, log := range logs { + item := get(log.Model, log.Capability) + item.row.Requests++ + item.users[log.UserID] = true + item.durations = append(item.durations, log.DurationMs) + if log.Status == model.ApiCallStatusSucceeded { + item.requestSuccess++ + } + if log.UsageAvailable { + item.row.UsageAvailable = true + item.row.InputTokens += log.InputTokens + item.row.OutputTokens += log.OutputTokens + item.row.CachedTokens += log.CachedTokens + } + item.row.MediaCount += log.MediaCount + item.row.VideoSeconds += log.VideoSeconds + } + result := make([]AnalyticsModelRow, 0, len(items)) + for _, item := range items { + item.row.UniqueUsers = len(item.users) + item.row.TaskSuccessRate = ratio(item.taskSuccess, item.taskTotal) + item.row.RequestSuccessRate = ratio(item.requestSuccess, item.row.Requests) + item.row.P50DurationMs = percentile(item.durations, 0.5) + item.row.P95DurationMs = percentile(item.durations, 0.95) + result = append(result, item.row) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Tasks == result[j].Tasks { + return result[i].Requests > result[j].Requests + } + return result[i].Tasks > result[j].Tasks + }) + return result +} + +func buildAnalyticsUsers(filter repository.AnalyticsFilter, tasks []model.Task, logs []model.ApiCallLog, activities []model.UserDailyActivity, users []model.User) []AnalyticsUserRow { + names := map[string]string{} + for _, user := range users { + names[user.ID] = firstNonEmpty(user.DisplayName, user.Username) + } + rows := map[string]*AnalyticsUserRow{} + models := map[string]map[string]int{} + get := func(userID string) *AnalyticsUserRow { + if rows[userID] == nil { + rows[userID] = &AnalyticsUserRow{UserID: userID, Name: firstNonEmpty(names[userID], userID)} + } + return rows[userID] + } + if !hasCreationDimensionFilter(filter) { + for _, activity := range activities { + if activity.Day.Before(filter.From) || !activity.Day.Before(filter.To) || !meaningfulActivity(activity) { + continue + } + row := get(activity.UserID) + row.ActiveDays++ + row.AgentMessages += activity.AgentMessageCount + if activity.CanvasActive { + row.CanvasDays++ + } + row.Assets += activity.AssetCount + row.Resources += activity.ResourceCount + } + } + for _, task := range tasks { + if models[task.UserID] == nil { + models[task.UserID] = map[string]int{} + } + models[task.UserID][task.Model]++ + get(task.UserID).Tasks++ + } + if hasCreationDimensionFilter(filter) { + activeDays := map[string]map[string]bool{} + for _, log := range logs { + get(log.UserID) + if activeDays[log.UserID] == nil { + activeDays[log.UserID] = map[string]bool{} + } + activeDays[log.UserID][log.CreatedAt.UTC().Format("2006-01-02")] = true + } + for userID, days := range activeDays { + get(userID).ActiveDays = len(days) + } + } + result := make([]AnalyticsUserRow, 0, len(rows)) + for userID, row := range rows { + bestCount := 0 + for modelName, count := range models[userID] { + if modelName != "" && count > bestCount { + row.CommonModel, bestCount = modelName, count + } + } + result = append(result, *row) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Tasks == result[j].Tasks { + return result[i].ActiveDays > result[j].ActiveDays + } + return result[i].Tasks > result[j].Tasks + }) + return result +} + +func buildAnalyticsFailures(logs []model.ApiCallLog) []AnalyticsFailureRow { + items := map[string]*AnalyticsFailureRow{} + for _, log := range logs { + if log.Status != model.ApiCallStatusFailed { + continue + } + typeName := classifyAPICallError(log) + modelName := firstNonEmpty(log.Model, "未识别") + key := typeName + "\x00" + modelName + if items[key] == nil { + items[key] = &AnalyticsFailureRow{Type: typeName, Model: modelName} + } + item := items[key] + item.Count++ + if log.CreatedAt.After(item.LastSeenAt) { + item.LastSeenAt = log.CreatedAt + item.LastError = truncateRunes(log.Error, 180) + } + } + result := make([]AnalyticsFailureRow, 0, len(items)) + for _, item := range items { + result = append(result, *item) + } + sort.Slice(result, func(i, j int) bool { return result[i].Count > result[j].Count }) + return result +} + +func classifyAPICallError(log model.ApiCallLog) string { + value := strings.ToLower(log.Error) + switch { + case log.ErrorCode == contentModerationErrorCode: + return "内容审核" + case strings.Contains(value, "timeout"), strings.Contains(value, "超时"), log.StatusCode == 408, log.StatusCode == 504, log.StatusCode == 524: + return "超时" + case log.StatusCode == 401 || log.StatusCode == 403 || strings.Contains(value, "unauthorized"): + return "鉴权失败" + case log.StatusCode == 429 || strings.Contains(value, "rate limit"): + return "限流" + case log.StatusCode >= 400 && log.StatusCode < 500: + return "请求参数" + case log.StatusCode >= 500: + return "上游服务" + case value != "": + return "网络或客户端" + default: + return "未知错误" + } +} + +func meaningfulActivity(activity model.UserDailyActivity) bool { + return activity.TaskCount > 0 || activity.AgentMessageCount > 0 || activity.CanvasActive || activity.AssetCount > 0 || activity.ResourceCount > 0 +} + +func rollingActiveUsers(activities []model.UserDailyActivity, tasks []model.Task, logs []model.ApiCallLog, from time.Time, to time.Time) int { + users := map[string]bool{} + for _, activity := range activities { + if !activity.Day.Before(from) && activity.Day.Before(to) && meaningfulActivity(activity) { + users[activity.UserID] = true + } + } + for _, task := range tasks { + if !task.CreatedAt.Before(from) && task.CreatedAt.Before(to) { + users[task.UserID] = true + } + } + for _, log := range logs { + if !log.CreatedAt.Before(from) && log.CreatedAt.Before(to) { + users[log.UserID] = true + } + } + return len(users) +} + +func successRateLogs(logs []model.ApiCallLog) float64 { + succeeded, failed := 0, 0 + for _, log := range logs { + if log.Status == model.ApiCallStatusSucceeded { + succeeded++ + } else if log.Status == model.ApiCallStatusFailed { + failed++ + } + } + return ratio(succeeded, succeeded+failed) +} + +func ratio(value int, total int) float64 { + if total == 0 { + return 0 + } + return float64(value) * 100 / float64(total) +} + +func percentile(values []int64, quantile float64) int64 { + if len(values) == 0 { + return 0 + } + items := append([]int64(nil), values...) + sort.Slice(items, func(i, j int) bool { return items[i] < items[j] }) + index := int(float64(len(items)-1)*quantile + 0.5) + return items[index] +} + +func capabilityFromTaskType(taskType string) string { + value := strings.ToLower(taskType) + for _, capability := range []string{"video", "image", "audio", "text"} { + if strings.Contains(value, capability) { + return capability + } + } + if strings.Contains(value, "storyboard") || strings.Contains(value, "agent") { + return "text" + } + return "" +} + +func hasCreationDimensionFilter(filter repository.AnalyticsFilter) bool { + return filter.Model != "" || filter.ChannelID != "" || filter.Capability != "" +} + +func tasksWithLoggedRequests(tasks []model.Task, logs []model.ApiCallLog) []model.Task { + ids := map[string]bool{} + for _, log := range logs { + if log.TaskID != "" { + ids[log.TaskID] = true + } + } + result := make([]model.Task, 0, len(tasks)) + for _, task := range tasks { + if ids[task.ID] { + result = append(result, task) + } + } + return result +} diff --git a/backend/internal/app/analytics_enrich.go b/backend/internal/app/analytics_enrich.go new file mode 100644 index 000000000..4b4793262 --- /dev/null +++ b/backend/internal/app/analytics_enrich.go @@ -0,0 +1,275 @@ +// 调用日志的费用估算与上游响应补全(请求 ID、视频 token、失败摘要)。 + +package app + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "strings" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (s *Service) estimateCallCost(log *model.ApiCallLog) { + if log.Status == model.ApiCallStatusFailed && !log.UsageAvailable { + return + } + pricing, err := s.repo.ModelPricing(log.ChannelID, log.Model, log.Capability) + if err != nil { + if !errors.Is(err, gorm.ErrRecordNotFound) { + return + } + return + } + cost := int64(0) + if log.Billable { + cost = pricing.PerRequestMicros + } + cost += log.InputTokens * pricing.InputPerMillionMicros / 1_000_000 + cost += log.OutputTokens * pricing.OutputPerMillionMicros / 1_000_000 + cost += log.CachedTokens * pricing.CachedPerMillionMicros / 1_000_000 + cost += int64(log.MediaCount) * pricing.PerMediaMicros + cost += int64(log.VideoSeconds) * pricing.PerVideoSecondMicros + log.EstimatedCostMicros = cost + log.CostAvailable = true + log.Currency = pricing.Currency +} + +func (s *Service) EnrichAPICallLog(log *model.ApiCallLog, responseBody []byte) { + if log == nil { + return + } + if log.ProviderRequestID == "" { + log.ProviderRequestID = providerRequestIDFromPath(log.Path) + } + payloads := providerResponsePayloads(responseBody) + for _, payload := range payloads { + s.enrichAPICallLogPayload(log, payload) + } + s.enrichAPICallLogFailureSummary(log, responseBody) +} + +func (s *Service) enrichAPICallLogFailureSummary(log *model.ApiCallLog, responseBody []byte) { + if log.Status != model.ApiCallStatusFailed || log.StatusCode < 400 { + return + } + userMessage := providerUserFacingErrorMessage(providerHTTPError{ + StatusCode: log.StatusCode, + Body: string(responseBody), + }) + detail := strings.TrimSpace(log.Error) + if detail == "" || detail == userMessage || strings.Contains(userMessage, ";上游:"+detail) { + log.Error = userMessage + return + } + if strings.Contains(detail, userMessage) { + return + } + log.Error = truncateRunes(userMessage+";上游:"+detail, 2_000) +} + +func (s *Service) enrichAPICallLogPayload(log *model.ApiCallLog, payload map[string]any) { + nestedTaskID := "" + if data, ok := payload["data"].(map[string]any); ok { + if log.Capability == "video" && strings.Contains(log.Path, "/v1/video/generations") { + if extracted, err := firstJSONString(data, "task_id", "taskId"); err == nil { + nestedTaskID = extracted + } + } + for key, value := range data { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + } + // Responses API 的终态 SSE 把实际响应(包括 usage)放在 response 字段中。 + if response, ok := payload["response"].(map[string]any); ok { + for key, value := range response { + if _, exists := payload[key]; !exists { + payload[key] = value + } + } + } + if log.Status == model.ApiCallStatusFailed { + errorCode, errorMessage := providerFailureDetails(payload) + log.ErrorCode = errorCode + if errorMessage != "" { + log.Error = errorMessage + } + } + arkVideo := log.Capability == "video" && strings.Contains(log.Path, "/contents/generations/tasks") + usage, _ := payload["usage"].(map[string]any) + if log.Capability == "video" { + log.InputTokens, log.CachedTokens = 0, 0 + log.OutputTokens, log.UsageAvailable = videoCompletionTokens(payload, arkVideo) + } else if usage != nil { + inputTokens, inputAvailable := firstInt64Value(usage, "input_tokens", "prompt_tokens") + outputTokens, outputAvailable := firstInt64Value(usage, "output_tokens", "completion_tokens") + if inputAvailable { + log.InputTokens = inputTokens + } + if outputAvailable { + log.OutputTokens = outputTokens + } + if inputAvailable || outputAvailable { + log.UsageAvailable = true + } + if details, ok := usage["input_tokens_details"].(map[string]any); ok { + log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens") + } + if details, ok := usage["prompt_tokens_details"].(map[string]any); ok && log.CachedTokens == 0 { + log.CachedTokens = firstInt64(details, "cached_tokens", "cache_read_input_tokens") + } + if log.CachedTokens == 0 { + log.CachedTokens = firstInt64(usage, "cached_tokens", "cache_read_input_tokens", "prompt_cache_hit_tokens") + } + } + if usageMetadata, ok := payload["usageMetadata"].(map[string]any); ok && log.Capability != "video" { + inputTokens, inputAvailable := firstInt64Value(usageMetadata, "promptTokenCount") + outputTokens, outputAvailable := firstInt64Value(usageMetadata, "candidatesTokenCount") + if inputAvailable { + log.InputTokens = inputTokens + } + if outputAvailable { + log.OutputTokens = outputTokens + } + if inputAvailable || outputAvailable { + log.UsageAvailable = true + } + log.CachedTokens = firstInt64(usageMetadata, "cachedContentTokenCount") + } + if extracted, err := firstJSONString(payload, "task_id", "id", "request_id", "name"); err == nil { + log.ProviderRequestID = firstNonEmpty(nestedTaskID, extracted, log.ProviderRequestID) + } else { + log.ProviderRequestID = firstNonEmpty(nestedTaskID, log.ProviderRequestID) + } + log.ProviderStatus = strings.ToLower(firstNonEmpty(stringField(payload, "status"), log.ProviderStatus)) + if log.ProviderStatus == "failed" || log.ProviderStatus == "cancelled" || log.ProviderStatus == "expired" { + log.Status = model.ApiCallStatusFailed + errorCode, errorMessage := providerFailureDetails(payload) + log.ErrorCode = firstNonEmpty(errorCode, log.ErrorCode) + log.Error = firstNonEmpty(errorMessage, log.Error) + } + if log.Capability == "image" { + if data, ok := payload["data"].([]any); ok { + log.MediaCount = len(data) + } else if images, ok := payload["images"].([]any); ok { + log.MediaCount = len(images) + } + } +} + +func videoCompletionTokens(payload map[string]any, arkProtocol bool) (int64, bool) { + if status, exists := payload["status"]; exists { + text, ok := status.(string) + status := strings.ToLower(strings.TrimSpace(text)) + if !ok || (status != "succeeded" && (arkProtocol || (status != "completed" && status != "success"))) { + return 0, false + } + } + usage, _ := payload["usage"].(map[string]any) + value, exists := usage["completion_tokens"] + if !exists && !arkProtocol { + value, exists = usage["output_tokens"] + } + if !exists { + value = usage["total_tokens"] + } + // completion_tokens 一旦返回就是唯一结算依据,非法值不能用 total_tokens 掩盖。 + // JSON Number 保留整数精度,避免浮点取整把小数或超出 int64 的值变成可计费用量。 + number, ok := value.(json.Number) + if !ok { + return 0, false + } + tokens, err := number.Int64() + if err != nil || tokens <= 0 { + return 0, false + } + return tokens, true +} + +func providerResponsePayloads(responseBody []byte) []map[string]any { + if len(responseBody) == 0 { + return nil + } + if payload, ok := decodeProviderResponsePayload(responseBody); ok { + return []map[string]any{payload} + } + + // 流式文本的用量只出现在最后一个 SSE data 事件中,不能把整段响应当作 JSON。 + result := make([]map[string]any, 0) + scanner := bufio.NewScanner(bytes.NewReader(responseBody)) + scanner.Buffer(make([]byte, 64<<10), max(len(responseBody)+1, 64<<10)) + dataLines := make([]string, 0, 1) + flush := func() { + raw := strings.TrimSpace(strings.Join(dataLines, "\n")) + dataLines = dataLines[:0] + if raw == "" || raw == "[DONE]" { + return + } + if event, ok := decodeProviderResponsePayload([]byte(raw)); ok { + result = append(result, event) + } + } + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + flush() + continue + } + if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + flush() + return result +} + +func decodeProviderResponsePayload(data []byte) (map[string]any, bool) { + if !json.Valid(data) { + return nil, false + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var payload map[string]any + err := decoder.Decode(&payload) + return payload, err == nil && payload != nil +} + +func providerRequestIDFromPath(path string) string { + parts := strings.Split(strings.Trim(strings.TrimSpace(path), "/"), "/") + for index := len(parts) - 1; index >= 0; index-- { + part := strings.TrimSpace(parts[index]) + if part == "" || part == "content" || part == "download" { + continue + } + if index > 0 && (parts[index-1] == "videos" || parts[index-1] == "tasks") { + return part + } + break + } + return "" +} + +func firstInt64(values map[string]any, keys ...string) int64 { + value, _ := firstInt64Value(values, keys...) + return value +} + +func firstInt64Value(values map[string]any, keys ...string) (int64, bool) { + for _, key := range keys { + switch value := values[key].(type) { + case float64: + return int64(value), true + case int64: + return value, true + case json.Number: + parsed, err := value.Int64() + return parsed, err == nil + } + } + return 0, false +} diff --git a/backend/internal/app/canvas_history_resource_test.go b/backend/internal/app/canvas_history_resource_test.go index 635bd5074..e3565bd27 100644 --- a/backend/internal/app/canvas_history_resource_test.go +++ b/backend/internal/app/canvas_history_resource_test.go @@ -67,7 +67,7 @@ func TestCanvasHistoryProtectsMediaAndDeletionWorker(t *testing.T) { } } -func TestArchivedAssetDeletionPreservesCanvasHistoryReference(t *testing.T) { +func TestArchivedAssetDeletionRemovesCanvasHistoryReference(t *testing.T) { svc, db, _ := newResourceDeletionTestService(t) resource := model.Resource{ ID: "archived-history-resource", UserID: "user-1", Provider: "unsupported-test-provider", @@ -88,18 +88,19 @@ func TestArchivedAssetDeletionPreservesCanvasHistoryReference(t *testing.T) { } } - if err := svc.DeleteUserAsset("user-1", asset.ID); err == nil || !strings.Contains(err.Error(), "画布历史版本") { - t.Fatalf("archived asset bypassed history protection: %v", err) + // 回收站素材的删除不受画布历史快照拦截:历史索引被移除、快照正文保留、物理文件进入 Outbox。 + if err := svc.DeleteUserAsset("user-1", asset.ID); err != nil { + t.Fatalf("archived asset deletion failed: %v", err) } for _, check := range []struct { model any want int64 }{ - {&model.Asset{}, 1}, - {&model.Resource{}, 1}, - {&model.CanvasSnapshotResource{}, 1}, + {&model.Asset{}, 0}, + {&model.Resource{}, 0}, + {&model.CanvasSnapshotResource{}, 0}, {&model.CanvasSnapshot{}, 1}, - {&model.ResourceDeletionJob{}, 0}, + {&model.ResourceDeletionJob{}, 1}, } { var count int64 if err := db.Model(check.model).Count(&count).Error; err != nil { diff --git a/backend/internal/app/channel_models.go b/backend/internal/app/channel_models.go index 797607540..5941ea29d 100644 --- a/backend/internal/app/channel_models.go +++ b/backend/internal/app/channel_models.go @@ -1,20 +1,15 @@ package app import ( - "context" "encoding/json" "errors" - "fmt" - "net/http" - "strconv" "strings" "time" + "gorm.io/gorm" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/protocol" "infinite-canvas/backend/internal/repository" - - "gorm.io/gorm" ) type ChannelModelRequest struct { @@ -143,159 +138,6 @@ func (s *Service) SystemChannelHasProtocol(channelID string, protocol model.Chan return false, nil } -func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - channel, err := s.adminSystemChannel(channelID) - if err != nil { - return nil, err - } - headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) - if err != nil { - return nil, err - } - // 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。 - models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers}) - if err != nil { - return nil, err - } - // 只按当前未删除记录去重;普通手动删除的模型仍可重新拉取,已合并进模型家族的 SKU 除外。 - existing, err := s.repo.ChannelModels(channelID, true) - if err != nil { - return nil, err - } - known := make(map[string]struct{}, len(existing)) - for _, item := range existing { - known[channelModelCatalogKey(item.ModelKey)] = struct{}{} - } - retired := retiredChannelModelKeys(channel.RetiredModelsJSON) - missing := make([]model.ChannelModel, 0, len(models)) - for _, name := range models { - name = strings.TrimPrefix(strings.TrimSpace(name), "models/") - key := channelModelCatalogKey(name) - if _, ok := known[key]; ok || retired[key] { - continue - } - // 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。 - modelID, idErr := s.repo.NextPrefixedID("MODEL") - if idErr != nil { - return nil, idErr - } - missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) - } - added, err := s.repo.CreateMissingChannelModels(missing) - if err != nil { - return nil, err - } - if added > 0 { - s.invalidateRouteCatalog() - } - return &AdminChannelModelFetchResult{Models: models, Added: added}, nil -} - -// PreviewAdminChannelModels 只读取上游模型目录,不修改渠道模型配置。 -func (s *Service) PreviewAdminChannelModels(ctx context.Context, actor *model.User, channelID string) ([]string, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - return s.fetchAdminChannelModelCatalog(ctx, actor, channelID) -} - -// ImportAdminChannelModels 只导入管理员明确选择、且仍存在于上游目录中的模型。 -func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.User, channelID string, selected []string) (*AdminChannelModelFetchResult, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - channel, err := s.adminSystemChannel(channelID) - if err != nil { - return nil, err - } - models, err := s.fetchAdminChannelModelCatalog(ctx, actor, channelID) - if err != nil { - return nil, err - } - if len(selected) == 0 { - return nil, BadAuthRequest("请至少选择一个要导入的模型") - } - if len(selected) > 500 { - return nil, BadAuthRequest("单次最多导入 500 个模型") - } - available := make(map[string]string, len(models)) - for _, name := range models { - available[channelModelCatalogKey(name)] = name - } - chosen := make([]string, 0, len(selected)) - seen := make(map[string]struct{}, len(selected)) - for _, rawName := range selected { - name := strings.TrimPrefix(strings.TrimSpace(rawName), "models/") - key := channelModelCatalogKey(name) - if key == "" { - continue - } - canonical, ok := available[key] - if !ok { - return nil, BadAuthRequest("所选模型不在上游模型目录中:" + name) - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - chosen = append(chosen, canonical) - } - if len(chosen) == 0 { - return nil, BadAuthRequest("请至少选择一个有效的模型") - } - - existing, err := s.repo.ChannelModels(channelID, true) - if err != nil { - return nil, err - } - known := make(map[string]struct{}, len(existing)) - for _, item := range existing { - providerKey := firstNonEmpty(item.ProviderModelKey, item.ModelKey) - known[channelModelCatalogKey(providerKey)] = struct{}{} - } - retired := retiredChannelModelKeys(channel.RetiredModelsJSON) - missing := make([]model.ChannelModel, 0, len(chosen)) - for _, name := range chosen { - key := channelModelCatalogKey(name) - if _, ok := known[key]; ok || retired[key] { - continue - } - modelID, idErr := s.repo.NextPrefixedID("MODEL") - if idErr != nil { - return nil, idErr - } - missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1}) - known[key] = struct{}{} - } - added, err := s.repo.CreateMissingChannelModels(missing) - if err != nil { - return nil, err - } - if added > 0 { - s.invalidateRouteCatalog() - } - return &AdminChannelModelFetchResult{Models: chosen, Added: added}, nil -} - -func (s *Service) fetchAdminChannelModelCatalog(ctx context.Context, actor *model.User, channelID string) ([]string, error) { - channel, err := s.adminSystemChannel(channelID) - if err != nil { - return nil, err - } - headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) - if err != nil { - return nil, err - } - models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers}) - if err != nil { - return nil, err - } - return uniqueNonEmpty(models), nil -} - func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id string, req ChannelModelRequest) (*model.ChannelModel, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -411,427 +253,6 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id return item, nil } -func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error { - if capability != "video" { - return nil - } - config, err := DecodeModelCapabilityConfig(rawCapabilityConfig) - if err != nil || config == nil || config.Video == nil { - return BadAuthRequest("视频模型能力配置无效,无法校验价格档规格") - } - resolutionSupported := make(map[string]bool, len(config.Video.Resolutions)) - for _, resolution := range config.Video.Resolutions { - resolutionSupported[normalizeChannelModelTierResolution(resolution)] = true - } - durationSupported := make(map[int]bool, len(config.Video.Duration.Values)) - for _, seconds := range config.Video.Duration.Values { - durationSupported[seconds] = true - } - for _, tier := range tiers { - if tier.Resolution != "*" && !resolutionSupported[normalizeChannelModelTierResolution(tier.Resolution)] { - return BadAuthRequest("价格档分辨率不在该视频模型支持范围内:" + tier.Resolution) - } - if tier.VideoSeconds == 0 { - continue - } - if !videoDurationSupported(config.Video) { - continue - } - if config.Video.Duration.Selection == "enum" && !durationSupported[tier.VideoSeconds] { - return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds)) - } - if config.Video.Duration.Selection == "range" && (tier.VideoSeconds < config.Video.Duration.Min || tier.VideoSeconds > config.Video.Duration.Max || (config.Video.Duration.Step > 0 && (tier.VideoSeconds-config.Video.Duration.Min)%config.Video.Duration.Step != 0)) { - return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds)) - } - } - return nil -} - -// syncLogicalModelsFromChannelModel 只失效路由目录。系统渠道 SKU 与前台模型目录 -// 分别维护:保存渠道模型绝不能自动创建、覆盖或删除前台模型及其线路配置。 -func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelModel *model.ChannelModel) error { - _ = actor - _ = channelModel - s.invalidateRouteCatalog() - return nil -} - -func supportsTokenBilling(capability string, _ model.ChannelInterfaceType) bool { - return capability == "text" || capability == "video" -} - -func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capability string, protocol model.ChannelInterfaceType, fallbackProviderModelKey string) ([]model.ChannelModelPriceTier, error) { - inputs := req.PriceTiers - // 兼容旧管理 API:没有 priceTiers 的请求等价于一个默认价格档。 - if len(inputs) == 0 { - enabled := true - inputs = []ChannelModelPriceTierRequest{{ - Resolution: "*", VideoSeconds: 0, ProviderModelKey: fallbackProviderModelKey, - BillingMode: req.BillingMode, UnitPriceMicrocredits: req.UnitPriceMicrocredits, - InputTokenPriceMicrocredits: req.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: req.OutputTokenPriceMicrocredits, - CachedTokenPriceMicrocredits: req.CachedTokenPriceMicrocredits, PriceConfigured: req.PriceConfigured, Enabled: &enabled, - }} - } - result := make([]model.ChannelModelPriceTier, 0, len(inputs)) - seen := make(map[string]bool, len(inputs)) - for _, input := range inputs { - selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input) - if selectorErr != nil { - return nil, selectorErr - } - _, key, keyErr := model.CanonicalSKUSelector(selector) - if keyErr != nil { - return nil, keyErr - } - if seen[key] { - return nil, BadAuthRequest("同一个操作和规格组合只能配置一个价格档") - } - seen[key] = true - billingMode := strings.TrimSpace(input.BillingMode) - if billingMode == "" { - billingMode = "fixed_request" - } - if err := validateChannelModelTierPricing(capability, protocol, billingMode, input); err != nil { - return nil, err - } - id, idErr := s.repo.NextPrefixedID("PTIER") - if idErr != nil { - return nil, idErr - } - enabled := input.Enabled == nil || *input.Enabled - result = append(result, model.ChannelModelPriceTier{ - CostPricing: input.CostPricing, - ID: id, - SelectorKey: key, - SelectorJSON: key, - Selector: selector, - Resolution: resolution, - VideoSeconds: videoSeconds, - ProviderModelKey: strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(input.ProviderModelKey, fallbackProviderModelKey)), "models/"), - BillingMode: billingMode, - UnitPriceMicrocredits: input.UnitPriceMicrocredits, - InputTokenPriceMicrocredits: input.InputTokenPriceMicrocredits, - OutputTokenPriceMicrocredits: input.OutputTokenPriceMicrocredits, - CachedTokenPriceMicrocredits: input.CachedTokenPriceMicrocredits, - PriceConfigured: input.PriceConfigured, - Enabled: enabled, - PriceVersion: 1, - }) - } - return result, nil -} - -func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest) (map[string]string, string, int, error) { - selector := make(map[string]string, len(input.Selector)+3) - for rawKey, rawValue := range input.Selector { - key := strings.TrimSpace(rawKey) - value := strings.TrimSpace(rawValue) - if key == "" || value == "" { - continue - } - switch key { - case "operation": - value = strings.ToLower(value) - case "quality", "size": - value = strings.ToLower(value) - if value == "auto" || value == "any" { - value = "*" - } - case "vquality": - value = normalizeChannelModelTierResolution(value) - case "videoSeconds": - seconds, err := strconv.Atoi(value) - if err != nil || seconds < 0 { - return nil, "", 0, BadAuthRequest("视频价格档时长必须是非负整数") - } - if seconds == 0 { - continue - } - value = strconv.Itoa(seconds) - case "imageCount": - count, err := strconv.Atoi(value) - if err != nil || count < 0 { - return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数") - } - if count == 0 { - continue - } - value = strconv.Itoa(count) - case "videoGenerateAudio": - if capability != "video" { - return nil, "", 0, BadAuthRequest("只有视频模型可以按是否生成音频配置价格档") - } - value = strings.ToLower(value) - if value == "*" { - continue - } - if value != "true" && value != "false" { - return nil, "", 0, BadAuthRequest("视频价格档音频条件必须为 true、false 或 *") - } - default: - return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) - } - selector[key] = value - } - if capability == "video" { - if _, exists := selector["vquality"]; !exists { - if resolution := normalizeChannelModelTierResolution(input.Resolution); resolution != "*" { - selector["vquality"] = resolution - } - } - if _, exists := selector["videoSeconds"]; !exists && input.VideoSeconds > 0 { - selector["videoSeconds"] = strconv.Itoa(input.VideoSeconds) - } - } else if input.Resolution != "" && normalizeChannelModelTierResolution(input.Resolution) != "*" { - return nil, "", 0, BadAuthRequest("非视频模型不能使用视频分辨率价格档") - } else if input.VideoSeconds != 0 { - return nil, "", 0, BadAuthRequest("非视频模型不能使用视频时长价格档") - } - for _, key := range []string{"quality", "size"} { - if _, exists := selector[key]; exists && capability != "image" { - return nil, "", 0, BadAuthRequest("只有图片模型可以按 " + key + " 配置价格档") - } - } - if _, exists := selector["vquality"]; exists && capability != "video" { - return nil, "", 0, BadAuthRequest("只有视频模型可以按分辨率配置价格档") - } - if _, exists := selector["videoSeconds"]; exists && capability != "video" { - return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档") - } - if _, exists := selector["imageCount"]; exists && capability != "video" { - return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档") - } - resolution := "*" - if value := selector["vquality"]; value != "" { - resolution = value - } - videoSeconds := 0 - if value := selector["videoSeconds"]; value != "" { - videoSeconds, _ = strconv.Atoi(value) - } - return selector, resolution, videoSeconds, nil -} - -func validateChannelModelTierPricing(capability string, protocol model.ChannelInterfaceType, billingMode string, input ChannelModelPriceTierRequest) error { - if err := validateCreditCostPricing(capability, billingMode, input.CostPricing); err != nil { - return err - } - if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { - return BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") - } - if billingMode == "per_second" && capability != "video" { - return BadAuthRequest("只有视频模型可以按秒计费") - } - if billingMode == "token" && !supportsTokenBilling(capability, protocol) { - return BadAuthRequest("Token 计费仅支持文本和视频模型") - } - if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 { - return BadAuthRequest("模型积分价格不能小于 0") - } - if billingMode == "token" { - return validateTokenPrices(capability, input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) - } - return validateTokenPrices("", input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) -} - -func normalizeChannelModelTierResolution(raw string) string { - value := strings.TrimSpace(raw) - if value == "" || value == "*" || strings.EqualFold(value, "any") { - return "*" - } - normalized := normalizeModelRequestOption("vquality", value) - return strings.ToLower(strings.TrimSpace(fmt.Sprint(normalized))) -} - -func (s *Service) applyChannelModelPriceTierSummary(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) { - item.PriceConfigured = false - item.BillingMode = "fixed_request" - item.UnitPriceMicrocredits = 0 - item.InputTokenPriceMicrocredits = 0 - item.OutputTokenPriceMicrocredits = 0 - item.CachedTokenPriceMicrocredits = 0 - var summary *model.ChannelModelPriceTier - for index := range tiers { - tier := &tiers[index] - if tier.Enabled && tier.PriceConfigured { - item.PriceConfigured = true - } - if summary == nil || (tier.Resolution == "*" && tier.VideoSeconds == 0) { - summary = tier - } - } - if summary == nil { - return - } - item.BillingMode = summary.BillingMode - item.UnitPriceMicrocredits = summary.UnitPriceMicrocredits - item.InputTokenPriceMicrocredits = summary.InputTokenPriceMicrocredits - item.OutputTokenPriceMicrocredits = summary.OutputTokenPriceMicrocredits - item.CachedTokenPriceMicrocredits = summary.CachedTokenPriceMicrocredits -} - -func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - channel, err := s.adminSystemChannel(channelID) - if err != nil { - return nil, err - } - modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req) - if err != nil { - return nil, err - } - if capability == "text" || capability == "image" || capability == "video" { - if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil { - return nil, err - } - } - if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" { - return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key") - } - if _, err := ValidateOutboundURL(channel.BaseURL); err != nil { - return nil, err - } - headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) - if err != nil { - return nil, err - } - - prompt := map[string]string{ - "text": "Reply with OK.", - "image": "A simple gray circle on a white background.", - "video": "A static gray circle on a white background.", - "audio": "Model test.", - }[capability] - videoSeconds := "6" - videoSecondsValue := 6 - if protocol == model.ChannelInterfaceVolcengineJiMengVideo { - videoSeconds = "5" - videoSecondsValue = 5 - } - imageSize, imageQuality := "", "" - var imageProfile *ImageCapabilityConfig - videoRatio, videoResolution := videoTestDefaults(nil) - var videoProfile *VideoCapabilityConfig - switch capability { - case "image": - profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) - if normalizeErr != nil { - return nil, normalizeErr - } - imageProfile = profile.Image - imageSize, imageQuality = imageTestDefaults(imageProfile) - case "video": - // 视频测试必须带上模型能力画像:声明式协议只按画像里的枚举回填分辨率名(如 480 -> 480p), - // 没有画像时会把裸数字发给上游,火山方舟等供应商会直接拒绝。 - profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) - if normalizeErr != nil { - return nil, normalizeErr - } - videoProfile = profile.Video - videoRatio, videoResolution = videoTestDefaults(videoProfile) - } - input := canvasGenerationInput{ - Mode: capability, - Prompt: prompt, - Config: providerConfig{ - ChannelID: channel.ID, - APIFormat: channel.APIFormat, - InterfaceType: string(protocol), - BaseURL: channel.BaseURL, - APIKey: channel.APIKey, - SecretKey: channel.SecretKey, - Headers: headers, - Model: providerModelKey, - ChannelModelKey: modelKey, - Size: map[string]string{"image": imageSize, "video": videoRatio}[capability], - Quality: imageQuality, - Count: "1", - VideoSeconds: videoSeconds, - VQuality: videoResolution, - VideoGenerateAudio: "false", - VideoWatermark: "false", - AudioVoice: "alloy", - AudioFormat: "mp3", - AudioSpeed: "1", - }, - Metadata: map[string]interface{}{}, - } - if capability == "image" { - input.ImageCapability = imageProfile - } - if capability == "video" { - input.VideoCapability = videoProfile - } - - // 测试复用真实生成协议、运行时并发和熔断策略,但不创建用户任务或计费订单。 - testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) - defer cancel() - testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{ - Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability, - Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, - }) - testCtx = withProtocolRegistry(testCtx, s.protocolRegistry()) - startedAt := time.Now() - switch capability { - case "text": - _, err = runTextTask(testCtx, input) - case "image": - _, err = runImageTask(testCtx, input) - case "video": - _, err = runVideoTask(testCtx, input) - case "audio": - _, err = runAudioTask(testCtx, input) - } - if err != nil { - status := http.StatusBadGateway - if errors.Is(err, context.DeadlineExceeded) { - status = http.StatusGatewayTimeout - } - return nil, WrapAppError(status, "模型测试失败:"+providerUserFacingErrorMessage(err), err) - } - return &AdminChannelModelTestResult{DurationMs: time.Since(startedAt).Milliseconds()}, nil -} - -// 模型测试必须使用当前模型声明的默认参数,避免固定分辨率 SKU 被通用 1K 测试值误伤。 -// videoTestDefaults 从模型能力画像取测试用的比例和分辨率;画像缺失时回退到最通用的 16:9 / 720。 -func videoTestDefaults(profile *VideoCapabilityConfig) (string, string) { - if profile == nil { - return "16:9", "720" - } - ratio := strings.TrimSpace(profile.DefaultRatio) - if ratio == "" && len(profile.Ratios) > 0 { - ratio = strings.TrimSpace(profile.Ratios[0]) - } - if ratio == "" { - ratio = "16:9" - } - resolution := strings.TrimSpace(profile.DefaultResolution) - if resolution == "" && len(profile.Resolutions) > 0 { - resolution = strings.TrimSpace(profile.Resolutions[0]) - } - if resolution == "" { - resolution = "720" - } - return ratio, resolution -} - -func imageTestDefaults(profile *ImageCapabilityConfig) (string, string) { - if profile == nil { - return "1024x1024", "auto" - } - size := "" - if profile.Size.Parameter != "none" { - size = strings.TrimSpace(profile.Size.Default) - } - quality := "" - if profile.Quality.Supported { - quality = strings.TrimSpace(profile.Quality.Default) - } - return size, quality -} - func normalizeChannelModelContract(channel *model.ModelChannel, req ChannelModelRequest) (string, string, string, model.ChannelInterfaceType, error) { return normalizeChannelModelContractWithRegistry(protocol.Builtins(), channel, req) } diff --git a/backend/internal/app/channel_models_catalog.go b/backend/internal/app/channel_models_catalog.go new file mode 100644 index 000000000..722e65a8f --- /dev/null +++ b/backend/internal/app/channel_models_catalog.go @@ -0,0 +1,163 @@ +// 渠道模型的上游目录拉取、导入预览与初始同步。 + +package app + +import ( + "context" + "strings" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) FetchAdminChannelModels(ctx context.Context, actor *model.User, channelID string) (*AdminChannelModelFetchResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + channel, err := s.adminSystemChannel(channelID) + if err != nil { + return nil, err + } + headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) + if err != nil { + return nil, err + } + // 使用服务端保存的渠道密钥和请求头访问上游,避免敏感配置再次经过浏览器。 + models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers}) + if err != nil { + return nil, err + } + // 只按当前未删除记录去重;普通手动删除的模型仍可重新拉取,已合并进模型家族的 SKU 除外。 + existing, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return nil, err + } + known := make(map[string]struct{}, len(existing)) + for _, item := range existing { + known[channelModelCatalogKey(item.ModelKey)] = struct{}{} + } + retired := retiredChannelModelKeys(channel.RetiredModelsJSON) + missing := make([]model.ChannelModel, 0, len(models)) + for _, name := range models { + name = strings.TrimPrefix(strings.TrimSpace(name), "models/") + key := channelModelCatalogKey(name) + if _, ok := known[key]; ok || retired[key] { + continue + } + // 自动发现不能绕过定价边界;新模型由管理员定价后再手动启用。 + modelID, idErr := s.repo.NextPrefixedID("MODEL") + if idErr != nil { + return nil, idErr + } + missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, ProviderModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceVersion: 1}) + } + added, err := s.repo.CreateMissingChannelModels(missing) + if err != nil { + return nil, err + } + if added > 0 { + s.invalidateRouteCatalog() + } + return &AdminChannelModelFetchResult{Models: models, Added: added}, nil +} + +// PreviewAdminChannelModels 只读取上游模型目录,不修改渠道模型配置。 +func (s *Service) PreviewAdminChannelModels(ctx context.Context, actor *model.User, channelID string) ([]string, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + return s.fetchAdminChannelModelCatalog(ctx, actor, channelID) +} + +// ImportAdminChannelModels 只导入管理员明确选择、且仍存在于上游目录中的模型。 +func (s *Service) ImportAdminChannelModels(ctx context.Context, actor *model.User, channelID string, selected []string) (*AdminChannelModelFetchResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + channel, err := s.adminSystemChannel(channelID) + if err != nil { + return nil, err + } + models, err := s.fetchAdminChannelModelCatalog(ctx, actor, channelID) + if err != nil { + return nil, err + } + if len(selected) == 0 { + return nil, BadAuthRequest("请至少选择一个要导入的模型") + } + if len(selected) > 500 { + return nil, BadAuthRequest("单次最多导入 500 个模型") + } + available := make(map[string]string, len(models)) + for _, name := range models { + available[channelModelCatalogKey(name)] = name + } + chosen := make([]string, 0, len(selected)) + seen := make(map[string]struct{}, len(selected)) + for _, rawName := range selected { + name := strings.TrimPrefix(strings.TrimSpace(rawName), "models/") + key := channelModelCatalogKey(name) + if key == "" { + continue + } + canonical, ok := available[key] + if !ok { + return nil, BadAuthRequest("所选模型不在上游模型目录中:" + name) + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + chosen = append(chosen, canonical) + } + if len(chosen) == 0 { + return nil, BadAuthRequest("请至少选择一个有效的模型") + } + + existing, err := s.repo.ChannelModels(channelID, true) + if err != nil { + return nil, err + } + known := make(map[string]struct{}, len(existing)) + for _, item := range existing { + providerKey := firstNonEmpty(item.ProviderModelKey, item.ModelKey) + known[channelModelCatalogKey(providerKey)] = struct{}{} + } + retired := retiredChannelModelKeys(channel.RetiredModelsJSON) + missing := make([]model.ChannelModel, 0, len(chosen)) + for _, name := range chosen { + key := channelModelCatalogKey(name) + if _, ok := known[key]; ok || retired[key] { + continue + } + modelID, idErr := s.repo.NextPrefixedID("MODEL") + if idErr != nil { + return nil, idErr + } + missing = append(missing, model.ChannelModel{ID: modelID, ChannelID: channelID, ModelKey: name, DisplayName: name, BillingMode: "fixed_request", Enabled: false, PriceConfigured: false, PriceVersion: 1}) + known[key] = struct{}{} + } + added, err := s.repo.CreateMissingChannelModels(missing) + if err != nil { + return nil, err + } + if added > 0 { + s.invalidateRouteCatalog() + } + return &AdminChannelModelFetchResult{Models: chosen, Added: added}, nil +} + +func (s *Service) fetchAdminChannelModelCatalog(ctx context.Context, actor *model.User, channelID string) ([]string, error) { + channel, err := s.adminSystemChannel(channelID) + if err != nil { + return nil, err + } + headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) + if err != nil { + return nil, err + } + models, err := s.FetchChannelModels(ctx, actor, ChannelModelsRequest{BaseURL: channel.BaseURL, APIKey: channel.APIKey, APIFormat: channel.APIFormat, Headers: headers}) + if err != nil { + return nil, err + } + return uniqueNonEmpty(models), nil +} diff --git a/backend/internal/app/channel_models_pricing.go b/backend/internal/app/channel_models_pricing.go new file mode 100644 index 000000000..878d5b57b --- /dev/null +++ b/backend/internal/app/channel_models_pricing.go @@ -0,0 +1,270 @@ +// 渠道模型价格档:选择器规范化、档位能力与计价校验、摘要回填。 + +package app + +import ( + "fmt" + "strconv" + "strings" + + "infinite-canvas/backend/internal/model" +) + +func validateChannelModelTierCapabilities(tiers []model.ChannelModelPriceTier, rawCapabilityConfig string, capability string) error { + if capability != "video" { + return nil + } + config, err := DecodeModelCapabilityConfig(rawCapabilityConfig) + if err != nil || config == nil || config.Video == nil { + return BadAuthRequest("视频模型能力配置无效,无法校验价格档规格") + } + resolutionSupported := make(map[string]bool, len(config.Video.Resolutions)) + for _, resolution := range config.Video.Resolutions { + resolutionSupported[normalizeChannelModelTierResolution(resolution)] = true + } + durationSupported := make(map[int]bool, len(config.Video.Duration.Values)) + for _, seconds := range config.Video.Duration.Values { + durationSupported[seconds] = true + } + for _, tier := range tiers { + if tier.Resolution != "*" && !resolutionSupported[normalizeChannelModelTierResolution(tier.Resolution)] { + return BadAuthRequest("价格档分辨率不在该视频模型支持范围内:" + tier.Resolution) + } + if tier.VideoSeconds == 0 { + continue + } + if !videoDurationSupported(config.Video) { + continue + } + if config.Video.Duration.Selection == "enum" && !durationSupported[tier.VideoSeconds] { + return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds)) + } + if config.Video.Duration.Selection == "range" && (tier.VideoSeconds < config.Video.Duration.Min || tier.VideoSeconds > config.Video.Duration.Max || (config.Video.Duration.Step > 0 && (tier.VideoSeconds-config.Video.Duration.Min)%config.Video.Duration.Step != 0)) { + return BadAuthRequest(fmt.Sprintf("价格档时长 %d 秒不在该视频模型支持范围内", tier.VideoSeconds)) + } + } + return nil +} + +// syncLogicalModelsFromChannelModel 只失效路由目录。系统渠道 SKU 与前台模型目录 +// 分别维护:保存渠道模型绝不能自动创建、覆盖或删除前台模型及其线路配置。 +func (s *Service) syncLogicalModelsFromChannelModel(actor *model.User, channelModel *model.ChannelModel) error { + _ = actor + _ = channelModel + s.invalidateRouteCatalog() + return nil +} + +func supportsTokenBilling(capability string, _ model.ChannelInterfaceType) bool { + return capability == "text" || capability == "video" +} + +func (s *Service) normalizeChannelModelPriceTiers(req ChannelModelRequest, capability string, protocol model.ChannelInterfaceType, fallbackProviderModelKey string) ([]model.ChannelModelPriceTier, error) { + inputs := req.PriceTiers + // 兼容旧管理 API:没有 priceTiers 的请求等价于一个默认价格档。 + if len(inputs) == 0 { + enabled := true + inputs = []ChannelModelPriceTierRequest{{ + Resolution: "*", VideoSeconds: 0, ProviderModelKey: fallbackProviderModelKey, + BillingMode: req.BillingMode, UnitPriceMicrocredits: req.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: req.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: req.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: req.CachedTokenPriceMicrocredits, PriceConfigured: req.PriceConfigured, Enabled: &enabled, + }} + } + result := make([]model.ChannelModelPriceTier, 0, len(inputs)) + seen := make(map[string]bool, len(inputs)) + for _, input := range inputs { + selector, resolution, videoSeconds, selectorErr := normalizeChannelModelTierSelector(capability, input) + if selectorErr != nil { + return nil, selectorErr + } + _, key, keyErr := model.CanonicalSKUSelector(selector) + if keyErr != nil { + return nil, keyErr + } + if seen[key] { + return nil, BadAuthRequest("同一个操作和规格组合只能配置一个价格档") + } + seen[key] = true + billingMode := strings.TrimSpace(input.BillingMode) + if billingMode == "" { + billingMode = "fixed_request" + } + if err := validateChannelModelTierPricing(capability, protocol, billingMode, input); err != nil { + return nil, err + } + id, idErr := s.repo.NextPrefixedID("PTIER") + if idErr != nil { + return nil, idErr + } + enabled := input.Enabled == nil || *input.Enabled + result = append(result, model.ChannelModelPriceTier{ + CostPricing: input.CostPricing, + ID: id, + SelectorKey: key, + SelectorJSON: key, + Selector: selector, + Resolution: resolution, + VideoSeconds: videoSeconds, + ProviderModelKey: strings.TrimPrefix(strings.TrimSpace(firstNonEmpty(input.ProviderModelKey, fallbackProviderModelKey)), "models/"), + BillingMode: billingMode, + UnitPriceMicrocredits: input.UnitPriceMicrocredits, + InputTokenPriceMicrocredits: input.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: input.OutputTokenPriceMicrocredits, + CachedTokenPriceMicrocredits: input.CachedTokenPriceMicrocredits, + PriceConfigured: input.PriceConfigured, + Enabled: enabled, + PriceVersion: 1, + }) + } + return result, nil +} + +func normalizeChannelModelTierSelector(capability string, input ChannelModelPriceTierRequest) (map[string]string, string, int, error) { + selector := make(map[string]string, len(input.Selector)+3) + for rawKey, rawValue := range input.Selector { + key := strings.TrimSpace(rawKey) + value := strings.TrimSpace(rawValue) + if key == "" || value == "" { + continue + } + switch key { + case "operation": + value = strings.ToLower(value) + case "quality", "size": + value = strings.ToLower(value) + if value == "auto" || value == "any" { + value = "*" + } + case "vquality": + value = normalizeChannelModelTierResolution(value) + case "videoSeconds": + seconds, err := strconv.Atoi(value) + if err != nil || seconds < 0 { + return nil, "", 0, BadAuthRequest("视频价格档时长必须是非负整数") + } + if seconds == 0 { + continue + } + value = strconv.Itoa(seconds) + case "imageCount": + count, err := strconv.Atoi(value) + if err != nil || count < 0 { + return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数") + } + if count == 0 { + continue + } + value = strconv.Itoa(count) + case "videoGenerateAudio": + if capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按是否生成音频配置价格档") + } + value = strings.ToLower(value) + if value == "*" { + continue + } + if value != "true" && value != "false" { + return nil, "", 0, BadAuthRequest("视频价格档音频条件必须为 true、false 或 *") + } + default: + return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key) + } + selector[key] = value + } + if capability == "video" { + if _, exists := selector["vquality"]; !exists { + if resolution := normalizeChannelModelTierResolution(input.Resolution); resolution != "*" { + selector["vquality"] = resolution + } + } + if _, exists := selector["videoSeconds"]; !exists && input.VideoSeconds > 0 { + selector["videoSeconds"] = strconv.Itoa(input.VideoSeconds) + } + } else if input.Resolution != "" && normalizeChannelModelTierResolution(input.Resolution) != "*" { + return nil, "", 0, BadAuthRequest("非视频模型不能使用视频分辨率价格档") + } else if input.VideoSeconds != 0 { + return nil, "", 0, BadAuthRequest("非视频模型不能使用视频时长价格档") + } + for _, key := range []string{"quality", "size"} { + if _, exists := selector[key]; exists && capability != "image" { + return nil, "", 0, BadAuthRequest("只有图片模型可以按 " + key + " 配置价格档") + } + } + if _, exists := selector["vquality"]; exists && capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按分辨率配置价格档") + } + if _, exists := selector["videoSeconds"]; exists && capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档") + } + if _, exists := selector["imageCount"]; exists && capability != "video" { + return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档") + } + resolution := "*" + if value := selector["vquality"]; value != "" { + resolution = value + } + videoSeconds := 0 + if value := selector["videoSeconds"]; value != "" { + videoSeconds, _ = strconv.Atoi(value) + } + return selector, resolution, videoSeconds, nil +} + +func validateChannelModelTierPricing(capability string, protocol model.ChannelInterfaceType, billingMode string, input ChannelModelPriceTierRequest) error { + if err := validateCreditCostPricing(capability, billingMode, input.CostPricing); err != nil { + return err + } + if billingMode != "fixed_request" && billingMode != "per_second" && billingMode != "token" { + return BadAuthRequest("模型计费方式仅支持按次、按秒或 Token") + } + if billingMode == "per_second" && capability != "video" { + return BadAuthRequest("只有视频模型可以按秒计费") + } + if billingMode == "token" && !supportsTokenBilling(capability, protocol) { + return BadAuthRequest("Token 计费仅支持文本和视频模型") + } + if input.UnitPriceMicrocredits < 0 || input.InputTokenPriceMicrocredits < 0 || input.OutputTokenPriceMicrocredits < 0 || input.CachedTokenPriceMicrocredits < 0 { + return BadAuthRequest("模型积分价格不能小于 0") + } + if billingMode == "token" { + return validateTokenPrices(capability, input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) + } + return validateTokenPrices("", input.InputTokenPriceMicrocredits, input.OutputTokenPriceMicrocredits, input.CachedTokenPriceMicrocredits) +} + +func normalizeChannelModelTierResolution(raw string) string { + value := strings.TrimSpace(raw) + if value == "" || value == "*" || strings.EqualFold(value, "any") { + return "*" + } + normalized := normalizeModelRequestOption("vquality", value) + return strings.ToLower(strings.TrimSpace(fmt.Sprint(normalized))) +} + +func (s *Service) applyChannelModelPriceTierSummary(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) { + item.PriceConfigured = false + item.BillingMode = "fixed_request" + item.UnitPriceMicrocredits = 0 + item.InputTokenPriceMicrocredits = 0 + item.OutputTokenPriceMicrocredits = 0 + item.CachedTokenPriceMicrocredits = 0 + var summary *model.ChannelModelPriceTier + for index := range tiers { + tier := &tiers[index] + if tier.Enabled && tier.PriceConfigured { + item.PriceConfigured = true + } + if summary == nil || (tier.Resolution == "*" && tier.VideoSeconds == 0) { + summary = tier + } + } + if summary == nil { + return + } + item.BillingMode = summary.BillingMode + item.UnitPriceMicrocredits = summary.UnitPriceMicrocredits + item.InputTokenPriceMicrocredits = summary.InputTokenPriceMicrocredits + item.OutputTokenPriceMicrocredits = summary.OutputTokenPriceMicrocredits + item.CachedTokenPriceMicrocredits = summary.CachedTokenPriceMicrocredits +} diff --git a/backend/internal/app/channel_models_test_call.go b/backend/internal/app/channel_models_test_call.go new file mode 100644 index 000000000..e609ae4ce --- /dev/null +++ b/backend/internal/app/channel_models_test_call.go @@ -0,0 +1,175 @@ +// 管理端「测试模型」:按能力构造最小测试请求并真实调用一次上游。 + +package app + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User, channelID string, req ChannelModelRequest) (*AdminChannelModelTestResult, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + channel, err := s.adminSystemChannel(channelID) + if err != nil { + return nil, err + } + modelKey, providerModelKey, capability, protocol, err := s.normalizeChannelModelContract(channel, req) + if err != nil { + return nil, err + } + if capability == "text" || capability == "image" || capability == "video" { + if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil { + return nil, err + } + } + if strings.TrimSpace(channel.BaseURL) == "" || strings.TrimSpace(channel.APIKey) == "" { + return nil, BadAuthRequest("请先在渠道中配置 Base URL 和 API Key") + } + if _, err := ValidateOutboundURL(channel.BaseURL); err != nil { + return nil, err + } + headers, err := ParseOutboundHeadersJSON(channel.HeadersJSON) + if err != nil { + return nil, err + } + + prompt := map[string]string{ + "text": "Reply with OK.", + "image": "A simple gray circle on a white background.", + "video": "A static gray circle on a white background.", + "audio": "Model test.", + }[capability] + videoSeconds := "6" + videoSecondsValue := 6 + if protocol == model.ChannelInterfaceVolcengineJiMengVideo { + videoSeconds = "5" + videoSecondsValue = 5 + } + imageSize, imageQuality := "", "" + var imageProfile *ImageCapabilityConfig + videoRatio, videoResolution := videoTestDefaults(nil) + var videoProfile *VideoCapabilityConfig + switch capability { + case "image": + profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) + if normalizeErr != nil { + return nil, normalizeErr + } + imageProfile = profile.Image + imageSize, imageQuality = imageTestDefaults(imageProfile) + case "video": + // 视频测试必须带上模型能力画像:声明式协议只按画像里的枚举回填分辨率名(如 480 -> 480p), + // 没有画像时会把裸数字发给上游,火山方舟等供应商会直接拒绝。 + profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig) + if normalizeErr != nil { + return nil, normalizeErr + } + videoProfile = profile.Video + videoRatio, videoResolution = videoTestDefaults(videoProfile) + } + input := canvasGenerationInput{ + Mode: capability, + Prompt: prompt, + Config: providerConfig{ + ChannelID: channel.ID, + APIFormat: channel.APIFormat, + InterfaceType: string(protocol), + BaseURL: channel.BaseURL, + APIKey: channel.APIKey, + SecretKey: channel.SecretKey, + Headers: headers, + Model: providerModelKey, + ChannelModelKey: modelKey, + Size: map[string]string{"image": imageSize, "video": videoRatio}[capability], + Quality: imageQuality, + Count: "1", + VideoSeconds: videoSeconds, + VQuality: videoResolution, + VideoGenerateAudio: "false", + VideoWatermark: "false", + AudioVoice: "alloy", + AudioFormat: "mp3", + AudioSpeed: "1", + }, + Metadata: map[string]interface{}{}, + } + if capability == "image" { + input.ImageCapability = imageProfile + } + if capability == "video" { + input.VideoCapability = videoProfile + } + + // 测试复用真实生成协议、运行时并发和熔断策略,但不创建用户任务或计费订单。 + testCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + testCtx = context.WithValue(testCtx, providerAnalyticsKey{}, providerAnalyticsContext{ + Service: s, Billing: s.taskBilling(), UserID: actor.ID, ChannelID: channel.ID, Capability: capability, + Operation: "admin_model_test", Model: modelKey, VideoSeconds: videoSecondsValue, + }) + testCtx = withProtocolRegistry(testCtx, s.protocolRegistry()) + startedAt := time.Now() + switch capability { + case "text": + _, err = runTextTask(testCtx, input) + case "image": + _, err = runImageTask(testCtx, input) + case "video": + _, err = runVideoTask(testCtx, input) + case "audio": + _, err = runAudioTask(testCtx, input) + } + if err != nil { + status := http.StatusBadGateway + if errors.Is(err, context.DeadlineExceeded) { + status = http.StatusGatewayTimeout + } + return nil, WrapAppError(status, "模型测试失败:"+providerUserFacingErrorMessage(err), err) + } + return &AdminChannelModelTestResult{DurationMs: time.Since(startedAt).Milliseconds()}, nil +} + +// 模型测试必须使用当前模型声明的默认参数,避免固定分辨率 SKU 被通用 1K 测试值误伤。 +// videoTestDefaults 从模型能力画像取测试用的比例和分辨率;画像缺失时回退到最通用的 16:9 / 720。 +func videoTestDefaults(profile *VideoCapabilityConfig) (string, string) { + if profile == nil { + return "16:9", "720" + } + ratio := strings.TrimSpace(profile.DefaultRatio) + if ratio == "" && len(profile.Ratios) > 0 { + ratio = strings.TrimSpace(profile.Ratios[0]) + } + if ratio == "" { + ratio = "16:9" + } + resolution := strings.TrimSpace(profile.DefaultResolution) + if resolution == "" && len(profile.Resolutions) > 0 { + resolution = strings.TrimSpace(profile.Resolutions[0]) + } + if resolution == "" { + resolution = "720" + } + return ratio, resolution +} + +func imageTestDefaults(profile *ImageCapabilityConfig) (string, string) { + if profile == nil { + return "1024x1024", "auto" + } + size := "" + if profile.Size.Parameter != "none" { + size = strings.TrimSpace(profile.Size.Default) + } + quality := "" + if profile.Quality.Supported { + quality = strings.TrimSpace(profile.Quality.Default) + } + return size, quality +} diff --git a/backend/internal/app/cloud_agent_approved_media_cancel_test.go b/backend/internal/app/cloud_agent_approved_media_cancel_test.go new file mode 100644 index 000000000..850ff0b97 --- /dev/null +++ b/backend/internal/app/cloud_agent_approved_media_cancel_test.go @@ -0,0 +1,50 @@ +package app + +import ( + "context" + "errors" + "testing" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// 停机时审批后的媒体等待会被取消。此时不能继续回写结果、也不能恢复运行时: +// 否则正在排空的进程会拉起新的 Agent 步骤,并把仍在生成的媒体任务当作失败处理。 +// MediaTaskID 必须原样保留,让下次启动的 recoverCloudAgentPiRunners 重新挂上等待。 +func TestFinishApprovedCloudAgentMediaStopsWhenWaitIsCancelled(t *testing.T) { + s, db, a := agentMediaFixture(t) + s.disablePiRuntime = true + run, state := agentMediaRun(t, s, a, "request_approval", "approved-media-cancel") + task := &model.Task{ID: "approved-media-task", UserID: "user", ProjectID: "agent-canvas", Type: "canvas_video", + Status: model.TaskStatusRunning, CreatedAt: time.Now(), UpdatedAt: time.Now()} + if err := db.Create(task).Error; err != nil { + t.Fatal(err) + } + state.MediaTaskID = task.ID + state.TaskIDs = append(state.TaskIDs, task.ID) + if err := s.repo.MutateCloudAgent("user", run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + return cloudAgentSave(current, &state) + }); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := s.finishApprovedCloudAgentMedia(ctx, "user", run.ID, task.ID) + if !errors.Is(err, context.Canceled) { + t.Fatalf("finishApprovedCloudAgentMedia() error = %v, want context.Canceled", err) + } + latest, err := s.repo.CloudAgent("user", run.ID) + if err != nil { + t.Fatal(err) + } + decoded, err := cloudAgentDecode(latest) + if err != nil { + t.Fatal(err) + } + if decoded.MediaTaskID != task.ID || decoded.PiResumePrompt != "" { + t.Fatalf("cancelled wait mutated run state: mediaTask=%q resumePrompt=%q", decoded.MediaTaskID, decoded.PiResumePrompt) + } +} diff --git a/backend/internal/app/cloud_agent_pi_bridge.go b/backend/internal/app/cloud_agent_pi_bridge.go new file mode 100644 index 000000000..2930b5bd0 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_bridge.go @@ -0,0 +1,459 @@ +// Agent 运行时与服务端之间的消息、工具与事件桥接。 +// +// 运行时上报的消息被转换回服务端的 canonical 历史;工具调用统一经 +// executeCloudAgentRuntimeTool 执行,与 Go 执行循环共用同一套校验与审批。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// canonicalFromRuntimeMessages 把运行时的会话消息(user/assistant/toolResult, +// 内容块为 text/image/thinking/toolCall)转换成供应商层接受的规范格式。 +func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string]interface{}, systemPrompt string) canonicalAgentRequest { + canonical := canonicalAgentRequest{Tools: tools, ToolChoice: "auto", SystemPrompt: systemPrompt} + for _, message := range messages { + switch stringField(message, "role") { + case "system": + if text := runtimeContentText(message["content"]); strings.TrimSpace(text) != "" { + canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "system", "content": text}) + } + case "user": + canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "user", "content": runtimeUserContent(message["content"])}) + case "assistant": + text := "" + calls := []interface{}{} + if parts, ok := message["content"].([]interface{}); ok { + for _, value := range parts { + part, _ := value.(map[string]interface{}) + switch stringField(part, "type") { + case "text": + text += stringField(part, "text") + case "toolCall": + arguments, _ := json.Marshal(part["arguments"]) + if part["arguments"] == nil { + arguments = []byte("{}") + } + calls = append(calls, map[string]interface{}{ + "id": stringField(part, "id"), + "function": map[string]interface{}{"name": stringField(part, "name"), "arguments": string(arguments)}, + }) + } + } + } else { + text = runtimeContentText(message["content"]) + } + if text == "" && len(calls) == 0 { + continue + } + converted := map[string]interface{}{"role": "assistant", "content": text} + if len(calls) > 0 { + converted["tool_calls"] = calls + } + canonical.Messages = append(canonical.Messages, converted) + case "toolResult", "tool": + id := firstNonEmpty(stringField(message, "toolCallId"), stringField(message, "tool_call_id")) + if id == "" { + continue + } + canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "tool", "tool_call_id": id, "content": runtimeContentText(message["content"])}) + } + } + if strings.TrimSpace(systemPrompt) != "" { + found := false + for _, message := range canonical.Messages { + if stringField(message, "role") == "system" { + found = true + break + } + } + if !found { + canonical.Messages = append([]map[string]interface{}{{"role": "system", "content": systemPrompt}}, canonical.Messages...) + } + } + return canonical +} + +func runtimeContentText(content interface{}) string { + if text, ok := content.(string); ok { + return text + } + parts, _ := content.([]interface{}) + texts := make([]string, 0, len(parts)) + for _, value := range parts { + part, _ := value.(map[string]interface{}) + if stringField(part, "type") == "text" { + texts = append(texts, stringField(part, "text")) + } + } + return strings.Join(texts, "\n") +} + +func runtimeUserContent(content interface{}) interface{} { + if text, ok := content.(string); ok { + return text + } + parts, _ := content.([]interface{}) + converted := make([]interface{}, 0, len(parts)) + for _, value := range parts { + part, _ := value.(map[string]interface{}) + switch stringField(part, "type") { + case "text": + converted = append(converted, map[string]interface{}{"type": "text", "text": stringField(part, "text")}) + case "image": + data, mimeType := stringField(part, "data"), stringField(part, "mimeType") + if data != "" && mimeType != "" { + converted = append(converted, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:" + mimeType + ";base64," + data}}) + } + case "image_url", "file_url": + converted = append(converted, part) + } + } + if len(converted) == 0 { + return "" + } + return converted +} + +// runtimeToolSchemas 把运行时声明的工具转换成规范的 function 工具定义。 +func runtimeToolSchemas(tools []map[string]any) []map[string]interface{} { + result := make([]map[string]interface{}, 0, len(tools)) + for _, tool := range tools { + name := stringValue(tool["name"]) + if name == "" { + continue + } + parameters := tool["parameters"] + if parameters == nil { + parameters = map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} + } + result = append(result, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": name, + "description": firstNonEmpty(stringValue(tool["description"]), name), + "parameters": parameters, + }, + }) + } + return result +} + +// runtimeToolCalls 把规范工具调用转换回运行时需要的 {id, name, arguments(object)}。 +func runtimeToolCalls(calls []cloudAgentCall) []map[string]any { + result := make([]map[string]any, 0, len(calls)) + for _, call := range calls { + arguments := map[string]any{} + if strings.TrimSpace(call.Function.Arguments) != "" { + _ = json.Unmarshal([]byte(call.Function.Arguments), &arguments) + } + result = append(result, map[string]any{"id": call.ID, "name": call.Function.Name, "arguments": arguments}) + } + return result +} + +// cloudAgentPiTool 工具调用桥接:所有工具都交给唯一的 Go 业务执行器 +// advanceCloudAgentTool(权限、审批、计费、画布写入都在那里治理)。 +func (s *Service) cloudAgentPiTool(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { + var request struct { + CallID string `json:"callId"` + Name string `json:"name"` + ToolName string `json:"toolName"` + Arguments json.RawMessage `json:"arguments"` + } + if err := decodePiPayload(payload, &request); err != nil { + return nil, err + } + var call cloudAgentCall + call.ID = firstNonEmpty(request.CallID, generateID()) + call.Function.Name = firstNonEmpty(request.Name, request.ToolName) + call.Function.Arguments = strings.TrimSpace(string(request.Arguments)) + if call.Function.Arguments == "" || call.Function.Arguments == "null" { + call.Function.Arguments = "{}" + } + if call.Function.Name == "" { + return nil, fmt.Errorf("tool call is missing a name") + } + + // 工具白名单校验:拒绝未声明的工具调用 + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, err + } + state, err := cloudAgentDecode(run) + if err != nil { + return nil, err + } + + allowedTools := make(map[string]bool) + for _, tool := range state.Canonical.Tools { + if fn, ok := tool["function"].(map[string]interface{}); ok { + name := stringValue(fn["name"]) + if name != "" { + allowedTools[name] = true + } + } + } + + if !allowedTools[call.Function.Name] { + log.Printf("[Agent] rejected undeclared tool call: %s in run %s", call.Function.Name, runID) + return nil, fmt.Errorf("未声明的工具: %s", call.Function.Name) + } + + return s.executeCloudAgentRuntimeTool(ctx, userID, runID, call) +} + +func (s *Service) executeCloudAgentRuntimeTool(ctx context.Context, userID, runID string, call cloudAgentCall) (any, error) { + executed := false + for attempt := 0; attempt < 8 && !executed; attempt++ { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, err + } + if cloudAgentRunTerminal(run.Status) { + return nil, fmt.Errorf("run already terminated") + } + state, err := cloudAgentDecode(run) + if err != nil { + return nil, err + } + if state.StepLimits, err = s.cloudAgentStepLimits(); err != nil { + return nil, err + } + state.Calls = []cloudAgentCall{call} + state.CallIndex = 0 + state.Approval = nil + err = s.advanceCloudAgentTool(run, &state) + if errors.Is(err, repository.ErrCreationConflict) { + time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) + continue + } + if err != nil { + return nil, err + } + executed = true + } + if !executed { + return nil, fmt.Errorf("execute tool %s: %w", call.Function.Name, repository.ErrCreationConflict) + } + // 媒体生成:等待已提交的任务结束,再由同一执行器回写画布并记录工具结果。 + for { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, err + } + state, err := cloudAgentDecode(run) + if err != nil { + return nil, err + } + if run.Status == "waiting_approval" && state.Approval != nil { + return map[string]any{"pause": true, "approvalId": state.Approval.ID, "content": "操作正在等待用户审批。"}, nil + } + if cloudAgentRunTerminal(run.Status) && run.Status != "completed" { + return nil, fmt.Errorf("%s", firstNonEmpty(run.FailureMessage, "Agent 工具执行失败")) + } + if content, ok := cloudAgentToolMessage(&state, call.ID); ok { + if trimmed := strings.TrimSpace(content); trimmed == "" || trimmed == "null" { + content = `{"error":"工具没有返回结果"}` + } + result := map[string]any{"content": content, "isError": cloudAgentToolContentIsError(content)} + if call.Function.Name == "ask_user" { + var payload map[string]any + if json.Unmarshal([]byte(content), &payload) == nil && stringValue(payload["phase"]) == "question" { + // ask_user 已经把本轮交给用户,Pi 不能再发起下一次模型调用。 + result["terminate"] = true + } + } + return result, nil + } + if state.MediaTaskID == "" { + return nil, fmt.Errorf("tool %s produced no result", call.Function.Name) + } + if _, err := s.waitCloudAgentTask(ctx, state.MediaTaskID); err != nil && ctx.Err() != nil { + return nil, ctx.Err() + } + if err := s.settleCloudAgentMedia(userID, runID); err != nil { + return nil, err + } + } +} + +// settleCloudAgentMedia 在媒体任务结束后用唯一的业务执行器回写画布、记录工具结果并释放 +// MediaTaskID。任务仍在生成时直接返回;并发写冲突时重读重试。 +func (s *Service) settleCloudAgentMedia(userID, runID string) error { + for attempt := 0; attempt < 8; attempt++ { + latest, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return err + } + fresh, err := cloudAgentDecode(latest) + if err != nil { + return err + } + if fresh.MediaTaskID == "" || fresh.CallIndex >= len(fresh.Calls) { + return nil + } + err = s.advanceCloudAgentMedia(latest, &fresh, fresh.Calls[fresh.CallIndex]) + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) + } + return repository.ErrCreationConflict +} + +func cloudAgentToolMessage(state *cloudAgentRuntime, callID string) (string, bool) { + for index := len(state.Canonical.Messages) - 1; index >= 0; index-- { + message := state.Canonical.Messages[index] + if stringField(message, "role") == "tool" && stringField(message, "tool_call_id") == callID { + return stringField(message, "content"), true + } + } + return "", false +} + +func cloudAgentToolContentIsError(content string) bool { + var decoded map[string]any + if json.Unmarshal([]byte(content), &decoded) != nil { + return false + } + _, failed := decoded["error"] + return failed +} + +// cloudAgentPiEvent 事件桥接。Node 端事件是平铺字段({type, message, ...}), +// 不是 {type, data};这里把整个 payload 作为事件数据交给处理器。 +func (s *Service) cloudAgentPiEvent(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { + data := map[string]any{} + for key, raw := range payload { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return nil, fmt.Errorf("decode Agent event field %q: %w", key, err) + } + data[key] = value + } + eventType, _ := data["type"].(string) + if nested, ok := data["data"].(map[string]any); ok { + for key, value := range nested { + if _, exists := data[key]; !exists { + data[key] = value + } + } + } + if message, ok := data["message"].(map[string]any); ok { + if _, exists := data["role"]; !exists { + data["role"] = message["role"] + } + } + + switch eventType { + case "message_start": + return s.handleMessageStart(userID, runID, data) + case "message_delta": + return s.handleMessageDelta(userID, runID, data) + case "message_end": + return s.handleMessageEnd(userID, runID, data) + case "session_snapshot": + return s.handlePiSessionSnapshot(userID, runID, data) + case "tool_call", "tool_call_start", "tool_call_end": + return s.handleToolCall(userID, runID, data) + case "error": + return s.handleError(userID, runID, data) + default: + return map[string]any{"ok": true}, nil + } +} + +// handlePiSessionSnapshot 持久化 Pi 原生会话 JSONL,用于审批恢复和续轮。 +func (s *Service) handlePiSessionSnapshot(userID, runID string, data map[string]any) (any, error) { + sessionJSONL, _ := data["sessionJSONL"].(string) + if sessionJSONL == "" { + return map[string]any{"ok": true}, nil + } + for attempt := 0; attempt < 4; attempt++ { + var expected int64 + existing, err := s.repo.CloudAgentPiSession(userID, runID) + if err == nil { + expected = existing.Revision + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + err = s.repo.SaveCloudAgentPiSession(&model.CloudAgentPiSession{RunID: runID, UserID: userID, SessionJSONL: sessionJSONL, UpdatedAt: time.Now()}, expected) + if err == nil { + if expected == 0 { + // OnConflict DoNothing:并发首写时再按 revision 覆盖一次。 + if saved, readErr := s.repo.CloudAgentPiSession(userID, runID); readErr == nil && saved.SessionJSONL != sessionJSONL { + continue + } + } + return map[string]any{"ok": true}, nil + } + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + return nil, fmt.Errorf("save Agent session: %w", repository.ErrCreationConflict) +} + +func stateCanonicalFromInput(input map[string]any) canonicalAgentRequest { + canonical, _ := canonicalAgentRequestFromInput(input) + return canonical +} + +func piToolDefinitions(source []map[string]interface{}) []map[string]any { + result := make([]map[string]any, 0, len(source)) + for _, tool := range source { + fn, _ := tool["function"].(map[string]interface{}) + name := stringValue(fn["name"]) + if name == "" { + continue + } + result = append(result, map[string]any{ + "name": name, + "label": firstNonEmpty(stringValue(fn["name"]), name), + "description": stringValue(fn["description"]), + "parameters": fn["parameters"], + }) + } + return result +} + +func isCanvasTool(toolName string) bool { + return strings.HasPrefix(toolName, "canvas_") +} + +func decodePiPayload(payload map[string]json.RawMessage, target interface{}) error { + // 尝试直接解析整个 payload + if data, err := json.Marshal(payload); err == nil { + if err := json.Unmarshal(data, target); err == nil { + return nil + } + } + + // 尝试解析 arguments 字段 + if argsRaw, ok := payload["arguments"]; ok { + return json.Unmarshal(argsRaw, target) + } + + return fmt.Errorf("failed to decode payload") +} + +func mustMarshal(v any) string { + data, _ := json.Marshal(v) + return string(data) +} + +func generateID() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} diff --git a/backend/internal/app/cloud_agent_pi_contract_test.go b/backend/internal/app/cloud_agent_pi_contract_test.go index 3fe13b055..077ae9b2b 100644 --- a/backend/internal/app/cloud_agent_pi_contract_test.go +++ b/backend/internal/app/cloud_agent_pi_contract_test.go @@ -9,9 +9,13 @@ import ( func TestCloudAgentProductionSchedulingDoesNotCallLegacyGoLoop(t *testing.T) { root := filepath.Join("..", "..") + // 生产调度入口:Agent 创建、运行时(含拆分出的调度/工具/媒体文件)与任务 worker。 + // 调度循环本身(cloud_agent_runtime_scheduler.go)定义了旧入口,只检查调用方。 files := []string{ filepath.Join(root, "internal", "app", "cloud_agent.go"), filepath.Join(root, "internal", "app", "cloud_agent_runtime.go"), + filepath.Join(root, "internal", "app", "cloud_agent_runtime_media.go"), + filepath.Join(root, "internal", "app", "cloud_agent_runtime_tools.go"), filepath.Join(root, "internal", "app", "task_worker.go"), } for _, file := range files { @@ -33,11 +37,16 @@ func TestCloudAgentProductionSchedulingDoesNotCallLegacyGoLoop(t *testing.T) { func TestCloudAgentPiRuntimeContractUsesGovernedStepBridge(t *testing.T) { root := filepath.Join("..", "..") - coordinator, err := os.ReadFile(filepath.Join(root, "internal", "app", "cloud_agent_pi_coordinator.go")) - if err != nil { - t.Fatal(err) + // 协调器拆分为协调、请求组装、模型桥与消息/工具桥四个文件,合同覆盖整组。 + var coordinator strings.Builder + for _, name := range []string{"cloud_agent_pi_coordinator.go", "cloud_agent_pi_request.go", "cloud_agent_pi_model.go", "cloud_agent_pi_bridge.go"} { + body, err := os.ReadFile(filepath.Join(root, "internal", "app", name)) + if err != nil { + t.Fatal(err) + } + coordinator.Write(body) } - text := string(coordinator) + text := coordinator.String() for _, required := range []string{ "Operation: cloudAgentStepOperation", "s.enqueueCloudAgentTask(run, &state, req, nil)", diff --git a/backend/internal/app/cloud_agent_pi_coordinator.go b/backend/internal/app/cloud_agent_pi_coordinator.go index dfb96be82..8187ad5c6 100644 --- a/backend/internal/app/cloud_agent_pi_coordinator.go +++ b/backend/internal/app/cloud_agent_pi_coordinator.go @@ -1,3 +1,11 @@ +// Agent 协调器:负责启动/恢复 Agent 运行时进程,并通过 /model、/tool、/event +// 三个本地桥接把模型调用、工具执行和事件持久化交回 Go 业务层治理。 +// +// 同一协调器的其余部分: +// - cloud_agent_pi_request.go 组装运行时请求 +// - cloud_agent_pi_model.go /model 桥:模型调用走平台文本任务 +// - cloud_agent_pi_bridge.go /tool、/event 桥与消息转换 + package app import ( @@ -6,10 +14,8 @@ import ( "errors" "fmt" "log" - "log/slog" "os" "path/filepath" - "strings" "sync/atomic" "time" @@ -18,9 +24,6 @@ import ( "infinite-canvas/backend/internal/repository" ) -// Agent 协调器:负责启动/恢复 Agent 运行时进程,并通过 /model、/tool、/event -// 三个本地桥接把模型调用、工具执行和事件持久化交回 Go 业务层治理。 - // startCloudAgentPi 是唯一的生产入口 func (s *Service) startCloudAgentPi(userID, runID string) { if s == nil || s.disablePiRuntime || runID == "" { @@ -363,266 +366,6 @@ func (s *Service) runCloudAgentPiSession(ctx context.Context, userID, runID stri return s.completeCloudAgentPiRun(userID, runID) } -// EnhancedPiRequestParams 增强的 Pi 请求参数 -type EnhancedPiRequestParams struct { - UserID string - RunID string - CanvasID string - FocusNodeIDs []string - Prompt string - SystemPrompt string - ModelID string - RuntimeState *cloudAgentRuntime - Canonical *canonicalAgentRequest - SessionFile string - SessionJSONL string - SessionDir string -} - -// buildEnhancedPiRequest 构建增强的 Pi 请求(核心方法) -func (s *Service) buildEnhancedPiRequest(ctx context.Context, params EnhancedPiRequestParams) (cloudAgentPiProcessRequest, error) { - slog.Debug("agent building request", "canvas", params.CanvasID) - - // 1. 构建工具列表(包括画布工具) - tools := s.buildCompletePiTools(params.Canonical.Tools) - - // 2. 构建 Skills 配置 - skillPaths := s.buildSkillPaths(params.RuntimeState.Skills) - enabledSkills := s.buildSkillManifests(params.RuntimeState.Skills) - - slog.Debug("agent enabled skills", "count", len(enabledSkills)) - - // 3. 构建 Profile 配置 - profile := s.buildProfileConfig(params.RuntimeState.Profile) - - // 4. 构建 Memory 配置 - memory := s.buildMemoryConfig(params.UserID, params.CanvasID) - - // 5. 构建画布智能上下文(核心创新) - canvasIntelligence := s.NewCanvasIntelligence() - enhancedCanvas, err := canvasIntelligence.BuildEnhancedCanvasContext(ctx, params.UserID, params.CanvasID, params.FocusNodeIDs) - if err != nil { - return cloudAgentPiProcessRequest{}, fmt.Errorf("build canvas intelligence: %w", err) - } - - canvasData, err := s.marshalEnhancedCanvas(enhancedCanvas) - if err != nil { - return cloudAgentPiProcessRequest{}, fmt.Errorf("marshal canvas: %w", err) - } - - slog.Debug("agent canvas intelligence", "nodes", - enhancedCanvas.Snapshot.TotalNodes, - "focus", len(enhancedCanvas.Snapshot.FocusNodes), - "relationships", len(enhancedCanvas.Snapshot.Relationships), - "clusters", len(enhancedCanvas.Snapshot.Layout.Clusters)) - - // 6. 构建 Features 配置 - features := s.buildFeaturesConfig(params.RuntimeState) - - // 7. 构建压缩策略 - compaction := s.buildCompactionStrategy(params.UserID, params.CanvasID) - - // 8. 构建权限配置 - permissions := s.buildPermissionsConfig(params.UserID, params.CanvasID) - - // 9. 构建模型配置 - modelConfig := s.buildModelConfig(params.ModelID, params.RuntimeState) - - // 10. 组装完整请求 - request := cloudAgentPiProcessRequest{ - // 会话信息 - SessionFile: params.SessionFile, - SessionJSONL: params.SessionJSONL, - SessionDir: params.SessionDir, - Cwd: s.dataDir, - AgentDir: params.SessionDir, - - // 标识信息 - SessionId: params.RunID, - UserId: params.UserID, - CanvasId: params.CanvasID, - RunId: params.RunID, - - // 对话内容 - Prompt: params.Prompt, - SystemPrompt: params.SystemPrompt, - - // 核心增强:完整的上下文传递 - SkillPaths: skillPaths, - EnabledSkills: enabledSkills, - Profile: profile, - Memory: memory, - Canvas: canvasData, - Features: features, - - // 配置 - Tools: tools, - Compaction: compaction, - Permissions: permissions, - Model: modelConfig, - } - - slog.Debug("agent request built", "canvas", params.CanvasID) - return request, nil -} - -// buildCompletePiTools 返回本轮授权的平台工具(与 Go 业务执行器同一份定义)。 -func (s *Service) buildCompletePiTools(canonicalTools []map[string]interface{}) []map[string]any { - return piToolDefinitions(canonicalTools) -} - -// buildProfileConfig 构建 Profile 配置 -func (s *Service) buildProfileConfig(profile cloudAgentProfileSnapshot) map[string]any { - layers := make([]map[string]any, 0, len(profile.Layers)) - for i, layer := range profile.Layers { - layers = append(layers, map[string]any{ - "scope": layer.Scope, - "content": layer.Content, - "priority": i, - "hash": hashContentSHA256(layer.Content), - }) - } - - return map[string]any{ - "revision": profile.Revision, - "hash": profile.Hash, - "layers": layers, - "enabled": true, - } -} - -// buildMemoryConfig 构建 Memory 配置 -func (s *Service) buildMemoryConfig(userID, canvasID string) map[string]any { - memoryDir := filepath.Join(s.dataDir, "memory", userID) - canvasMemoryDir := filepath.Join(memoryDir, canvasID) - - return map[string]any{ - "enabled": true, - "storePath": memoryDir, - "canvasStorePath": canvasMemoryDir, - "userId": userID, - "canvasId": canvasID, - "maxEntries": 1000, - "indexingEnabled": true, - "searchEnabled": true, - "autoSaveInterval": 60, // seconds - "compressionEnabled": true, - } -} - -// buildFeaturesConfig 构建 Features 配置 -func (s *Service) buildFeaturesConfig(state *cloudAgentRuntime) map[string]any { - return map[string]any{ - "planningEnabled": true, - "formsEnabled": true, - "memoryEnabled": true, - "skillsEnabled": len(state.Skills) > 0, - "canvasIntelligence": true, - "approvalRequired": []string{"canvas_node_delete", "canvas_bulk_operation"}, - "autoSave": true, - "collaborationMode": "multi-user", - "versionControl": true, - } -} - -// buildPermissionsConfig 构建权限配置 -func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any { - // 从数据库读取实际权限 - canvas, err := s.repo.GetCanvas(userID, canvasID) - if err != nil { - log.Printf("[Agent] failed to get canvas for permissions: %v", err) - // 返回最小权限集 - return map[string]any{ - "canReadCanvas": true, - "canWriteCanvas": false, - "canDeleteNodes": false, - "canCreateNodes": false, - "canMoveNodes": false, - "canDuplicateNodes": false, - "canManageRelations": false, - "canInviteUsers": false, - "canExportCanvas": true, - "maxTokenBudget": 200000, - "maxSteps": 50, - } - } - - // 检查用户是否是画布所有者 - isOwner := canvas.UserID == userID - - // 检查协作权限 - canWrite := isOwner - canDelete := isOwner - canInvite := isOwner - - if canvas.Metadata != nil { - if collaborators, ok := canvas.Metadata["collaborators"].([]any); ok { - for _, collab := range collaborators { - if collabMap, ok := collab.(map[string]any); ok { - if collabUserID, _ := collabMap["userId"].(string); collabUserID == userID { - role, _ := collabMap["role"].(string) - switch role { - case "admin": - canWrite = true - canDelete = true - canInvite = true - case "editor": - canWrite = true - case "viewer": - // 只读权限 - } - } - } - } - } - } - - return map[string]any{ - "canReadCanvas": true, - "canWriteCanvas": canWrite, - "canDeleteNodes": canDelete, - "canCreateNodes": canWrite, - "canMoveNodes": canWrite, - "canDuplicateNodes": canWrite, - "canManageRelations": canWrite, - "canInviteUsers": canInvite, - "canExportCanvas": true, - "maxTokenBudget": 200000, - "maxSteps": 50, - } -} - -// buildModelConfig 构建模型配置 -func (s *Service) buildModelConfig(modelID string, state *cloudAgentRuntime) map[string]any { - return map[string]any{ - "id": modelID, - "name": modelID, - "reasoning": cloudAgentReasoningEnabled(state.Policy.ReasoningMode), - "input": []string{"text", "image"}, - "contextWindow": 200000, - "maxTokens": 8192, - "provider": state.Request.ChannelID, - "switchable": true, - "temperature": 0.7, - "topP": 0.9, - } -} - -// marshalEnhancedCanvas 序列化增强画布 -func (s *Service) marshalEnhancedCanvas(canvas *EnhancedCanvasContext) (map[string]any, error) { - data, err := json.Marshal(canvas) - if err != nil { - return nil, err - } - - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - return nil, err - } - - return result, nil -} - // completeCloudAgentPiRun 完成运行 func (s *Service) completeCloudAgentPiRun(userID, runID string) error { for attempt := 0; attempt < 4; attempt++ { @@ -673,709 +416,3 @@ func (s *Service) completeCloudAgentPiRun(userID, runID string) error { return repository.ErrCreationConflict } - -// Pi 桥接方法 - -// cloudAgentPiModel 模型调用桥接 -func (s *Service) cloudAgentPiModel(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { - var request struct { - Messages []map[string]any `json:"messages"` - Tools []map[string]any `json:"tools"` - ThinkingLevel string `json:"thinkingLevel"` - } - if err := decodePiPayload(payload, &request); err != nil { - return nil, err - } - if len(request.Messages) == 0 { - return nil, fmt.Errorf("no messages") - } - - // 上游临时故障(5xx、429、超时、连接错误、空回复)自动重试,首次失败后最多再试 3 次, - // 指数退避。参数/鉴权类 4xx 重试也不会变好,直接失败。 - // 模型吐出损坏的工具参数 JSON(截断或多一个括号)时,那次调用并没有被执行: - // 与 Go 执行循环的 correctCloudAgentTruncatedCalls 一致,附上 truncated_tool_arguments - // 纠偏上下文重做这一步,而不是把整轮判死。次数与临时故障共用同一个上限。 - var lastErr error - var correction []map[string]any - for attempt := 0; attempt <= cloudAgentModelStepRetries; attempt++ { - if attempt > 0 { - delay := time.Duration(1<<(attempt-1)) * time.Second - log.Printf("[Agent] model step retry run=%s attempt=%d/%d after %s: %v", runID, attempt, cloudAgentModelStepRetries, delay, lastErr) - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-time.After(delay): - } - } - result, retryable, err := s.runCloudAgentModelStep(ctx, userID, runID, request.Messages, request.ThinkingLevel, correction...) - if err == nil { - return result, nil - } - lastErr = err - if !retryable { - return nil, err - } - if errors.Is(err, errCloudAgentTruncatedToolArguments) && correction == nil { - correction = []map[string]any{cloudAgentRuntimeMessage(cloudAgentRuntimeContext{Kind: cloudAgentContextTruncatedArguments})} - } - } - return nil, lastErr -} - -// cloudAgentModelStepRetries 是单步模型调用首次失败后的最大重试次数。 -const cloudAgentModelStepRetries = 3 - -// errCloudAgentTruncatedToolArguments 标记"模型返回的工具参数不是完整 JSON": -// 调用未执行,可以带纠偏上下文重做同一步。 -var errCloudAgentTruncatedToolArguments = errors.New("truncated tool arguments") - -// runCloudAgentModelStep 调度并等待一次模型步骤;第二个返回值表示失败是否值得重试。 -func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID string, messages []map[string]any, thinkingLevel string, correction ...map[string]any) (any, bool, error) { - // 运行时发起 /model 的同时会并发推送事件(message_start、session_snapshot 等), - // 这些事件也会推进运行 revision。调度模型步骤是 CAS 写入:冲突时重新读取 - // 最新状态再调度,而不是把整轮判失败。冲突时事务整体回滚,不会产生任务或扣费。 - var state cloudAgentRuntime - for attempt := 0; attempt < 8; attempt++ { - run, err := s.repo.CloudAgent(userID, runID) - if err != nil { - return nil, false, err - } - state, err = cloudAgentDecode(run) - if err != nil { - return nil, false, err - } - if cloudAgentRunTerminal(run.Status) { - return nil, false, fmt.Errorf("run already terminated") - } - if cloudAgentStepBudgetExhausted(&state) { - return nil, false, fmt.Errorf("step budget exhausted") - } - if state.StepLimits, err = s.cloudAgentStepLimits(); err != nil { - return nil, false, err - } - // 一个运行同一时刻只能有一个活动任务。媒体任务还没回写时不能再入队模型步骤, - // 否则检查点校验会拒绝("多个活动任务")并让整轮失败。先等媒体结果落库。 - if state.MediaTaskID != "" { - if _, waitErr := s.waitCloudAgentTask(ctx, state.MediaTaskID); waitErr != nil && ctx.Err() != nil { - return nil, false, ctx.Err() - } - if err := s.settleCloudAgentMedia(userID, runID); err != nil { - return nil, false, err - } - continue - } - - canonical := canonicalFromRuntimeMessages(messages, state.Canonical.Tools, state.Canonical.SystemPrompt) - // A continuation starts a fresh Pi process. If its session snapshot was - // unavailable, the runtime transcript is shorter than the server-authored - // canonical history. Reuse canonical directly so provider tool_calls are - // not accidentally converted as if they were Pi content blocks. - if len(state.Canonical.Messages) > len(messages) { - canonical = state.Canonical - } - canonical.PromptCacheKey = state.Canonical.PromptCacheKey - if len(canonical.Messages) == 0 { - return nil, false, fmt.Errorf("no canonical messages") - } - state.Canonical = canonical - if len(correction) > 0 { - // 纠偏上下文只用于这一次请求,不写回运行状态,避免下一步重复出现。 - canonical.Messages = append(append([]map[string]any(nil), canonical.Messages...), correction...) - } - input := map[string]any{ - "mode": "text", - "prompt": state.Request.Prompt, - "agentRequests": map[string]any{"canonical": canonical}, - "config": map[string]any{ - "channelId": state.Request.ChannelID, - "channelModelKey": state.Request.ChannelModelKey, - "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model), - }, - "textOptions": map[string]any{ - "stream": true, - "thinking": thinkingLevel != "off", - "maxOutputTokens": cloudAgentStepOutputBudget(state.StepLimits, state.BoostStepOutputBudget), - }, - } - req := CreateTaskRequest{ - ProjectID: state.Request.CanvasID, - Type: "canvas_text", - // Operation: cloudAgentStepOperation - Operation: cloudAgentStepOperation, - Prompt: state.Request.Prompt, - Model: firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model), - LogicalModelID: state.Request.LogicalModelID, - Input: input, - } - err = s.enqueueCloudAgentTask(run, &state, req, nil) - if errors.Is(err, repository.ErrCreationConflict) { - state.ActiveTaskID = "" - time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) - continue - } - if err != nil { - return nil, false, err - } - break - } - if state.ActiveTaskID == "" { - return nil, false, fmt.Errorf("Agent model step was not scheduled: %w", repository.ErrCreationConflict) - } - // 任务已入队:立刻唤醒调度器,不等下一次轮询。 - s.wakeTaskDispatcher() - taskID := state.ActiveTaskID - task, err := s.waitCloudAgentTask(ctx, taskID) - if err != nil { - truncated := false - if failed, lookupErr := s.repo.Task(taskID); lookupErr == nil && failed != nil && failed.Status != model.TaskStatusSucceeded { - truncated = cloudAgentTruncatedToolArguments(failed) - } - retryable := ctx.Err() == nil && (truncated || s.cloudAgentModelTaskRetryable(taskID)) - if retryable { - // 释放失败的步骤,下一次重试才能重新入队。 - if releaseErr := s.finishCloudAgentPiModelStep(userID, runID, taskID, "", ""); releaseErr != nil { - return nil, false, releaseErr - } - } - if truncated { - err = fmt.Errorf("%w: %v", errCloudAgentTruncatedToolArguments, err) - } - return nil, retryable, err - } - var result struct { - Text string `json:"text"` - Reasoning string `json:"reasoning,omitempty"` - ToolCalls []cloudAgentCall `json:"toolCalls"` - } - if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { - return nil, false, fmt.Errorf("decode model result: %w", err) - } - if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, result.Text, result.Reasoning); err != nil { - return nil, false, err - } - return map[string]any{"text": result.Text, "reasoning": result.Reasoning, "toolCalls": runtimeToolCalls(result.ToolCalls)}, false, nil -} - -// cloudAgentModelTaskRetryable 按上游真实 HTTP 状态判断失败是否是临时性的: -// 5xx、429、408 与没有拿到任何响应的网络错误可以重试;空回复也可以重试。 -// 其余 4xx(参数、鉴权、模型不存在)重试结果不会变,直接失败。 -func (s *Service) cloudAgentModelTaskRetryable(taskID string) bool { - task, err := s.repo.Task(taskID) - if err != nil || task == nil || task.Status == model.TaskStatusSucceeded || task.Status == model.TaskStatusCancelled { - return false - } - if cloudAgentEmptyModelOutput(task) || cloudAgentStepTimedOut(task) { - return true - } - status, err := s.repo.LatestAPICallStatusForTask(taskID) - if err != nil { - return false - } - switch { - case status >= 500, status == 429, status == 408: - return true - case status == 0: - // 没有上游响应:连接失败、重置、超时。 - raw := strings.ToLower(task.Error) - return strings.Contains(raw, "timeout") || strings.Contains(raw, "deadline") || strings.Contains(raw, "connection") || strings.Contains(raw, "eof") || strings.Contains(task.Error, "超时") - } - return false -} - -// finishCloudAgentPiModelStep 释放已完成的模型步骤,并把最终正文写入 Agent 事件流。 -// 不释放 ActiveTaskID 的话,completeCloudAgentPiRun 会一直认为还有任务在跑。 -func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reasoning string) error { - for attempt := 0; attempt < 8; attempt++ { - run, err := s.repo.CloudAgent(userID, runID) - if err != nil { - return err - } - err = s.repo.MutateCloudAgent(userID, runID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - fresh, err := cloudAgentDecode(current) - if err != nil { - return err - } - if fresh.ActiveTaskID != taskID { - return nil - } - fresh.ActiveTaskID = "" - fresh.ActiveTextDraft = "" - if reasoning != "" { - fresh.event(runID, "reasoning_message", map[string]any{"messageId": taskID + ":reasoning", "text": truncateRunes(reasoning, 8000)}) - } - if text != "" { - fresh.event(runID, "assistant_message", map[string]any{"messageId": taskID, "text": text}) - } - return cloudAgentSave(current, &fresh) - }) - if !errors.Is(err, repository.ErrCreationConflict) { - return err - } - } - return repository.ErrCreationConflict -} - -func (s *Service) waitCloudAgentTask(ctx context.Context, taskID string) (*model.Task, error) { - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - task, err := s.repo.Task(taskID) - if err != nil { - return nil, err - } - if cloudAgentTaskTerminal(task.Status) { - if task.Status != model.TaskStatusSucceeded { - // 与画布节点同一套失败分类(网络/审核/存储/HTTP 状态/供应商原因), - // 原始错误保留在任务中心诊断里,不直接抛给用户。 - return nil, BadAuthRequest(userFacingTaskFailure(task)) - } - return task, nil - } - select { - case <-ctx.Done(): - return nil, ctx.Err() - case <-ticker.C: - } - } -} - -// canonicalFromRuntimeMessages 把运行时的会话消息(user/assistant/toolResult, -// 内容块为 text/image/thinking/toolCall)转换成供应商层接受的规范格式。 -func canonicalFromRuntimeMessages(messages []map[string]any, tools []map[string]interface{}, systemPrompt string) canonicalAgentRequest { - canonical := canonicalAgentRequest{Tools: tools, ToolChoice: "auto", SystemPrompt: systemPrompt} - for _, message := range messages { - switch stringField(message, "role") { - case "system": - if text := runtimeContentText(message["content"]); strings.TrimSpace(text) != "" { - canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "system", "content": text}) - } - case "user": - canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "user", "content": runtimeUserContent(message["content"])}) - case "assistant": - text := "" - calls := []interface{}{} - if parts, ok := message["content"].([]interface{}); ok { - for _, value := range parts { - part, _ := value.(map[string]interface{}) - switch stringField(part, "type") { - case "text": - text += stringField(part, "text") - case "toolCall": - arguments, _ := json.Marshal(part["arguments"]) - if part["arguments"] == nil { - arguments = []byte("{}") - } - calls = append(calls, map[string]interface{}{ - "id": stringField(part, "id"), - "function": map[string]interface{}{"name": stringField(part, "name"), "arguments": string(arguments)}, - }) - } - } - } else { - text = runtimeContentText(message["content"]) - } - if text == "" && len(calls) == 0 { - continue - } - converted := map[string]interface{}{"role": "assistant", "content": text} - if len(calls) > 0 { - converted["tool_calls"] = calls - } - canonical.Messages = append(canonical.Messages, converted) - case "toolResult", "tool": - id := firstNonEmpty(stringField(message, "toolCallId"), stringField(message, "tool_call_id")) - if id == "" { - continue - } - canonical.Messages = append(canonical.Messages, map[string]interface{}{"role": "tool", "tool_call_id": id, "content": runtimeContentText(message["content"])}) - } - } - if strings.TrimSpace(systemPrompt) != "" { - found := false - for _, message := range canonical.Messages { - if stringField(message, "role") == "system" { - found = true - break - } - } - if !found { - canonical.Messages = append([]map[string]interface{}{{"role": "system", "content": systemPrompt}}, canonical.Messages...) - } - } - return canonical -} - -func runtimeContentText(content interface{}) string { - if text, ok := content.(string); ok { - return text - } - parts, _ := content.([]interface{}) - texts := make([]string, 0, len(parts)) - for _, value := range parts { - part, _ := value.(map[string]interface{}) - if stringField(part, "type") == "text" { - texts = append(texts, stringField(part, "text")) - } - } - return strings.Join(texts, "\n") -} - -func runtimeUserContent(content interface{}) interface{} { - if text, ok := content.(string); ok { - return text - } - parts, _ := content.([]interface{}) - converted := make([]interface{}, 0, len(parts)) - for _, value := range parts { - part, _ := value.(map[string]interface{}) - switch stringField(part, "type") { - case "text": - converted = append(converted, map[string]interface{}{"type": "text", "text": stringField(part, "text")}) - case "image": - data, mimeType := stringField(part, "data"), stringField(part, "mimeType") - if data != "" && mimeType != "" { - converted = append(converted, map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": "data:" + mimeType + ";base64," + data}}) - } - case "image_url", "file_url": - converted = append(converted, part) - } - } - if len(converted) == 0 { - return "" - } - return converted -} - -// runtimeToolSchemas 把运行时声明的工具转换成规范的 function 工具定义。 -func runtimeToolSchemas(tools []map[string]any) []map[string]interface{} { - result := make([]map[string]interface{}, 0, len(tools)) - for _, tool := range tools { - name := stringValue(tool["name"]) - if name == "" { - continue - } - parameters := tool["parameters"] - if parameters == nil { - parameters = map[string]interface{}{"type": "object", "properties": map[string]interface{}{}} - } - result = append(result, map[string]interface{}{ - "type": "function", - "function": map[string]interface{}{ - "name": name, - "description": firstNonEmpty(stringValue(tool["description"]), name), - "parameters": parameters, - }, - }) - } - return result -} - -// runtimeToolCalls 把规范工具调用转换回运行时需要的 {id, name, arguments(object)}。 -func runtimeToolCalls(calls []cloudAgentCall) []map[string]any { - result := make([]map[string]any, 0, len(calls)) - for _, call := range calls { - arguments := map[string]any{} - if strings.TrimSpace(call.Function.Arguments) != "" { - _ = json.Unmarshal([]byte(call.Function.Arguments), &arguments) - } - result = append(result, map[string]any{"id": call.ID, "name": call.Function.Name, "arguments": arguments}) - } - return result -} - -// cloudAgentPiTool 工具调用桥接:所有工具都交给唯一的 Go 业务执行器 -// advanceCloudAgentTool(权限、审批、计费、画布写入都在那里治理)。 -func (s *Service) cloudAgentPiTool(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { - var request struct { - CallID string `json:"callId"` - Name string `json:"name"` - ToolName string `json:"toolName"` - Arguments json.RawMessage `json:"arguments"` - } - if err := decodePiPayload(payload, &request); err != nil { - return nil, err - } - var call cloudAgentCall - call.ID = firstNonEmpty(request.CallID, generateID()) - call.Function.Name = firstNonEmpty(request.Name, request.ToolName) - call.Function.Arguments = strings.TrimSpace(string(request.Arguments)) - if call.Function.Arguments == "" || call.Function.Arguments == "null" { - call.Function.Arguments = "{}" - } - if call.Function.Name == "" { - return nil, fmt.Errorf("tool call is missing a name") - } - - // 工具白名单校验:拒绝未声明的工具调用 - run, err := s.repo.CloudAgent(userID, runID) - if err != nil { - return nil, err - } - state, err := cloudAgentDecode(run) - if err != nil { - return nil, err - } - - allowedTools := make(map[string]bool) - for _, tool := range state.Canonical.Tools { - if fn, ok := tool["function"].(map[string]interface{}); ok { - name := stringValue(fn["name"]) - if name != "" { - allowedTools[name] = true - } - } - } - - if !allowedTools[call.Function.Name] { - log.Printf("[Agent] rejected undeclared tool call: %s in run %s", call.Function.Name, runID) - return nil, fmt.Errorf("未声明的工具: %s", call.Function.Name) - } - - return s.executeCloudAgentRuntimeTool(ctx, userID, runID, call) -} - -func (s *Service) executeCloudAgentRuntimeTool(ctx context.Context, userID, runID string, call cloudAgentCall) (any, error) { - executed := false - for attempt := 0; attempt < 8 && !executed; attempt++ { - run, err := s.repo.CloudAgent(userID, runID) - if err != nil { - return nil, err - } - if cloudAgentRunTerminal(run.Status) { - return nil, fmt.Errorf("run already terminated") - } - state, err := cloudAgentDecode(run) - if err != nil { - return nil, err - } - if state.StepLimits, err = s.cloudAgentStepLimits(); err != nil { - return nil, err - } - state.Calls = []cloudAgentCall{call} - state.CallIndex = 0 - state.Approval = nil - err = s.advanceCloudAgentTool(run, &state) - if errors.Is(err, repository.ErrCreationConflict) { - time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) - continue - } - if err != nil { - return nil, err - } - executed = true - } - if !executed { - return nil, fmt.Errorf("execute tool %s: %w", call.Function.Name, repository.ErrCreationConflict) - } - // 媒体生成:等待已提交的任务结束,再由同一执行器回写画布并记录工具结果。 - for { - run, err := s.repo.CloudAgent(userID, runID) - if err != nil { - return nil, err - } - state, err := cloudAgentDecode(run) - if err != nil { - return nil, err - } - if run.Status == "waiting_approval" && state.Approval != nil { - return map[string]any{"pause": true, "approvalId": state.Approval.ID, "content": "操作正在等待用户审批。"}, nil - } - if cloudAgentRunTerminal(run.Status) && run.Status != "completed" { - return nil, fmt.Errorf("%s", firstNonEmpty(run.FailureMessage, "Agent 工具执行失败")) - } - if content, ok := cloudAgentToolMessage(&state, call.ID); ok { - if trimmed := strings.TrimSpace(content); trimmed == "" || trimmed == "null" { - content = `{"error":"工具没有返回结果"}` - } - result := map[string]any{"content": content, "isError": cloudAgentToolContentIsError(content)} - if call.Function.Name == "ask_user" { - var payload map[string]any - if json.Unmarshal([]byte(content), &payload) == nil && stringValue(payload["phase"]) == "question" { - // ask_user 已经把本轮交给用户,Pi 不能再发起下一次模型调用。 - result["terminate"] = true - } - } - return result, nil - } - if state.MediaTaskID == "" { - return nil, fmt.Errorf("tool %s produced no result", call.Function.Name) - } - if _, err := s.waitCloudAgentTask(ctx, state.MediaTaskID); err != nil && ctx.Err() != nil { - return nil, ctx.Err() - } - if err := s.settleCloudAgentMedia(userID, runID); err != nil { - return nil, err - } - } -} - -// settleCloudAgentMedia 在媒体任务结束后用唯一的业务执行器回写画布、记录工具结果并释放 -// MediaTaskID。任务仍在生成时直接返回;并发写冲突时重读重试。 -func (s *Service) settleCloudAgentMedia(userID, runID string) error { - for attempt := 0; attempt < 8; attempt++ { - latest, err := s.repo.CloudAgent(userID, runID) - if err != nil { - return err - } - fresh, err := cloudAgentDecode(latest) - if err != nil { - return err - } - if fresh.MediaTaskID == "" || fresh.CallIndex >= len(fresh.Calls) { - return nil - } - err = s.advanceCloudAgentMedia(latest, &fresh, fresh.Calls[fresh.CallIndex]) - if !errors.Is(err, repository.ErrCreationConflict) { - return err - } - time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) - } - return repository.ErrCreationConflict -} - -func cloudAgentToolMessage(state *cloudAgentRuntime, callID string) (string, bool) { - for index := len(state.Canonical.Messages) - 1; index >= 0; index-- { - message := state.Canonical.Messages[index] - if stringField(message, "role") == "tool" && stringField(message, "tool_call_id") == callID { - return stringField(message, "content"), true - } - } - return "", false -} - -func cloudAgentToolContentIsError(content string) bool { - var decoded map[string]any - if json.Unmarshal([]byte(content), &decoded) != nil { - return false - } - _, failed := decoded["error"] - return failed -} - -// cloudAgentPiEvent 事件桥接。Node 端事件是平铺字段({type, message, ...}), -// 不是 {type, data};这里把整个 payload 作为事件数据交给处理器。 -func (s *Service) cloudAgentPiEvent(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { - data := map[string]any{} - for key, raw := range payload { - var value any - if err := json.Unmarshal(raw, &value); err != nil { - return nil, fmt.Errorf("decode Agent event field %q: %w", key, err) - } - data[key] = value - } - eventType, _ := data["type"].(string) - if nested, ok := data["data"].(map[string]any); ok { - for key, value := range nested { - if _, exists := data[key]; !exists { - data[key] = value - } - } - } - if message, ok := data["message"].(map[string]any); ok { - if _, exists := data["role"]; !exists { - data["role"] = message["role"] - } - } - - switch eventType { - case "message_start": - return s.handleMessageStart(userID, runID, data) - case "message_delta": - return s.handleMessageDelta(userID, runID, data) - case "message_end": - return s.handleMessageEnd(userID, runID, data) - case "session_snapshot": - return s.handlePiSessionSnapshot(userID, runID, data) - case "tool_call", "tool_call_start", "tool_call_end": - return s.handleToolCall(userID, runID, data) - case "error": - return s.handleError(userID, runID, data) - default: - return map[string]any{"ok": true}, nil - } -} - -// handlePiSessionSnapshot 持久化 Pi 原生会话 JSONL,用于审批恢复和续轮。 -func (s *Service) handlePiSessionSnapshot(userID, runID string, data map[string]any) (any, error) { - sessionJSONL, _ := data["sessionJSONL"].(string) - if sessionJSONL == "" { - return map[string]any{"ok": true}, nil - } - for attempt := 0; attempt < 4; attempt++ { - var expected int64 - existing, err := s.repo.CloudAgentPiSession(userID, runID) - if err == nil { - expected = existing.Revision - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - err = s.repo.SaveCloudAgentPiSession(&model.CloudAgentPiSession{RunID: runID, UserID: userID, SessionJSONL: sessionJSONL, UpdatedAt: time.Now()}, expected) - if err == nil { - if expected == 0 { - // OnConflict DoNothing:并发首写时再按 revision 覆盖一次。 - if saved, readErr := s.repo.CloudAgentPiSession(userID, runID); readErr == nil && saved.SessionJSONL != sessionJSONL { - continue - } - } - return map[string]any{"ok": true}, nil - } - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - } - return nil, fmt.Errorf("save Agent session: %w", repository.ErrCreationConflict) -} - -// 辅助方法 - -func stateCanonicalFromInput(input map[string]any) canonicalAgentRequest { - canonical, _ := canonicalAgentRequestFromInput(input) - return canonical -} - -func piToolDefinitions(source []map[string]interface{}) []map[string]any { - result := make([]map[string]any, 0, len(source)) - for _, tool := range source { - fn, _ := tool["function"].(map[string]interface{}) - name := stringValue(fn["name"]) - if name == "" { - continue - } - result = append(result, map[string]any{ - "name": name, - "label": firstNonEmpty(stringValue(fn["name"]), name), - "description": stringValue(fn["description"]), - "parameters": fn["parameters"], - }) - } - return result -} - -func isCanvasTool(toolName string) bool { - return strings.HasPrefix(toolName, "canvas_") -} - -func decodePiPayload(payload map[string]json.RawMessage, target interface{}) error { - // 尝试直接解析整个 payload - if data, err := json.Marshal(payload); err == nil { - if err := json.Unmarshal(data, target); err == nil { - return nil - } - } - - // 尝试解析 arguments 字段 - if argsRaw, ok := payload["arguments"]; ok { - return json.Unmarshal(argsRaw, target) - } - - return fmt.Errorf("failed to decode payload") -} - -func mustMarshal(v any) string { - data, _ := json.Marshal(v) - return string(data) -} - -func generateID() string { - return fmt.Sprintf("%d", time.Now().UnixNano()) -} diff --git a/backend/internal/app/cloud_agent_pi_model.go b/backend/internal/app/cloud_agent_pi_model.go new file mode 100644 index 000000000..926753495 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_model.go @@ -0,0 +1,282 @@ +// Agent 运行时的模型调用桥:把运行时的一次模型请求转成平台文本任务并等待结果。 +// +// 模型步走平台任务体系(计费、渠道路由、超时都复用文本任务);临时故障与 +// 截断的工具参数会在同一步内重试,次数上限见 cloudAgentModelStepRetries。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// cloudAgentPiModel 模型调用桥接 +func (s *Service) cloudAgentPiModel(ctx context.Context, userID, runID string, payload map[string]json.RawMessage) (any, error) { + var request struct { + Messages []map[string]any `json:"messages"` + Tools []map[string]any `json:"tools"` + ThinkingLevel string `json:"thinkingLevel"` + } + if err := decodePiPayload(payload, &request); err != nil { + return nil, err + } + if len(request.Messages) == 0 { + return nil, fmt.Errorf("no messages") + } + + // 上游临时故障(5xx、429、超时、连接错误、空回复)自动重试,首次失败后最多再试 3 次, + // 指数退避。参数/鉴权类 4xx 重试也不会变好,直接失败。 + // 模型吐出损坏的工具参数 JSON(截断或多一个括号)时,那次调用并没有被执行: + // 与 Go 执行循环的 correctCloudAgentTruncatedCalls 一致,附上 truncated_tool_arguments + // 纠偏上下文重做这一步,而不是把整轮判死。次数与临时故障共用同一个上限。 + var lastErr error + var correction []map[string]any + for attempt := 0; attempt <= cloudAgentModelStepRetries; attempt++ { + if attempt > 0 { + delay := time.Duration(1<<(attempt-1)) * time.Second + log.Printf("[Agent] model step retry run=%s attempt=%d/%d after %s: %v", runID, attempt, cloudAgentModelStepRetries, delay, lastErr) + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + } + result, retryable, err := s.runCloudAgentModelStep(ctx, userID, runID, request.Messages, request.ThinkingLevel, correction...) + if err == nil { + return result, nil + } + lastErr = err + if !retryable { + return nil, err + } + if errors.Is(err, errCloudAgentTruncatedToolArguments) && correction == nil { + correction = []map[string]any{cloudAgentRuntimeMessage(cloudAgentRuntimeContext{Kind: cloudAgentContextTruncatedArguments})} + } + } + return nil, lastErr +} + +// cloudAgentModelStepRetries 是单步模型调用首次失败后的最大重试次数。 +const cloudAgentModelStepRetries = 3 + +// errCloudAgentTruncatedToolArguments 标记"模型返回的工具参数不是完整 JSON": +// 调用未执行,可以带纠偏上下文重做同一步。 +var errCloudAgentTruncatedToolArguments = errors.New("truncated tool arguments") + +// runCloudAgentModelStep 调度并等待一次模型步骤;第二个返回值表示失败是否值得重试。 +func (s *Service) runCloudAgentModelStep(ctx context.Context, userID, runID string, messages []map[string]any, thinkingLevel string, correction ...map[string]any) (any, bool, error) { + // 运行时发起 /model 的同时会并发推送事件(message_start、session_snapshot 等), + // 这些事件也会推进运行 revision。调度模型步骤是 CAS 写入:冲突时重新读取 + // 最新状态再调度,而不是把整轮判失败。冲突时事务整体回滚,不会产生任务或扣费。 + var state cloudAgentRuntime + for attempt := 0; attempt < 8; attempt++ { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return nil, false, err + } + state, err = cloudAgentDecode(run) + if err != nil { + return nil, false, err + } + if cloudAgentRunTerminal(run.Status) { + return nil, false, fmt.Errorf("run already terminated") + } + if cloudAgentStepBudgetExhausted(&state) { + return nil, false, fmt.Errorf("step budget exhausted") + } + if state.StepLimits, err = s.cloudAgentStepLimits(); err != nil { + return nil, false, err + } + // 一个运行同一时刻只能有一个活动任务。媒体任务还没回写时不能再入队模型步骤, + // 否则检查点校验会拒绝("多个活动任务")并让整轮失败。先等媒体结果落库。 + if state.MediaTaskID != "" { + if _, waitErr := s.waitCloudAgentTask(ctx, state.MediaTaskID); waitErr != nil && ctx.Err() != nil { + return nil, false, ctx.Err() + } + if err := s.settleCloudAgentMedia(userID, runID); err != nil { + return nil, false, err + } + continue + } + + canonical := canonicalFromRuntimeMessages(messages, state.Canonical.Tools, state.Canonical.SystemPrompt) + // A continuation starts a fresh Pi process. If its session snapshot was + // unavailable, the runtime transcript is shorter than the server-authored + // canonical history. Reuse canonical directly so provider tool_calls are + // not accidentally converted as if they were Pi content blocks. + if len(state.Canonical.Messages) > len(messages) { + canonical = state.Canonical + } + canonical.PromptCacheKey = state.Canonical.PromptCacheKey + if len(canonical.Messages) == 0 { + return nil, false, fmt.Errorf("no canonical messages") + } + state.Canonical = canonical + if len(correction) > 0 { + // 纠偏上下文只用于这一次请求,不写回运行状态,避免下一步重复出现。 + canonical.Messages = append(append([]map[string]any(nil), canonical.Messages...), correction...) + } + input := map[string]any{ + "mode": "text", + "prompt": state.Request.Prompt, + "agentRequests": map[string]any{"canonical": canonical}, + "config": map[string]any{ + "channelId": state.Request.ChannelID, + "channelModelKey": state.Request.ChannelModelKey, + "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model), + }, + "textOptions": map[string]any{ + "stream": true, + "thinking": thinkingLevel != "off", + "maxOutputTokens": cloudAgentStepOutputBudget(state.StepLimits, state.BoostStepOutputBudget), + }, + } + req := CreateTaskRequest{ + ProjectID: state.Request.CanvasID, + Type: "canvas_text", + // Operation: cloudAgentStepOperation + Operation: cloudAgentStepOperation, + Prompt: state.Request.Prompt, + Model: firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model), + LogicalModelID: state.Request.LogicalModelID, + Input: input, + } + err = s.enqueueCloudAgentTask(run, &state, req, nil) + if errors.Is(err, repository.ErrCreationConflict) { + state.ActiveTaskID = "" + time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond) + continue + } + if err != nil { + return nil, false, err + } + break + } + if state.ActiveTaskID == "" { + return nil, false, fmt.Errorf("Agent model step was not scheduled: %w", repository.ErrCreationConflict) + } + // 任务已入队:立刻唤醒调度器,不等下一次轮询。 + s.wakeTaskDispatcher() + taskID := state.ActiveTaskID + task, err := s.waitCloudAgentTask(ctx, taskID) + if err != nil { + truncated := false + if failed, lookupErr := s.repo.Task(taskID); lookupErr == nil && failed != nil && failed.Status != model.TaskStatusSucceeded { + truncated = cloudAgentTruncatedToolArguments(failed) + } + retryable := ctx.Err() == nil && (truncated || s.cloudAgentModelTaskRetryable(taskID)) + if retryable { + // 释放失败的步骤,下一次重试才能重新入队。 + if releaseErr := s.finishCloudAgentPiModelStep(userID, runID, taskID, "", ""); releaseErr != nil { + return nil, false, releaseErr + } + } + if truncated { + err = fmt.Errorf("%w: %v", errCloudAgentTruncatedToolArguments, err) + } + return nil, retryable, err + } + var result struct { + Text string `json:"text"` + Reasoning string `json:"reasoning,omitempty"` + ToolCalls []cloudAgentCall `json:"toolCalls"` + } + if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { + return nil, false, fmt.Errorf("decode model result: %w", err) + } + if err := s.finishCloudAgentPiModelStep(userID, runID, task.ID, result.Text, result.Reasoning); err != nil { + return nil, false, err + } + return map[string]any{"text": result.Text, "reasoning": result.Reasoning, "toolCalls": runtimeToolCalls(result.ToolCalls)}, false, nil +} + +// cloudAgentModelTaskRetryable 按上游真实 HTTP 状态判断失败是否是临时性的: +// 5xx、429、408 与没有拿到任何响应的网络错误可以重试;空回复也可以重试。 +// 其余 4xx(参数、鉴权、模型不存在)重试结果不会变,直接失败。 +func (s *Service) cloudAgentModelTaskRetryable(taskID string) bool { + task, err := s.repo.Task(taskID) + if err != nil || task == nil || task.Status == model.TaskStatusSucceeded || task.Status == model.TaskStatusCancelled { + return false + } + if cloudAgentEmptyModelOutput(task) || cloudAgentStepTimedOut(task) { + return true + } + status, err := s.repo.LatestAPICallStatusForTask(taskID) + if err != nil { + return false + } + switch { + case status >= 500, status == 429, status == 408: + return true + case status == 0: + // 没有上游响应:连接失败、重置、超时。 + raw := strings.ToLower(task.Error) + return strings.Contains(raw, "timeout") || strings.Contains(raw, "deadline") || strings.Contains(raw, "connection") || strings.Contains(raw, "eof") || strings.Contains(task.Error, "超时") + } + return false +} + +// finishCloudAgentPiModelStep 释放已完成的模型步骤,并把最终正文写入 Agent 事件流。 +// 不释放 ActiveTaskID 的话,completeCloudAgentPiRun 会一直认为还有任务在跑。 +func (s *Service) finishCloudAgentPiModelStep(userID, runID, taskID, text, reasoning string) error { + for attempt := 0; attempt < 8; attempt++ { + run, err := s.repo.CloudAgent(userID, runID) + if err != nil { + return err + } + err = s.repo.MutateCloudAgent(userID, runID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + fresh, err := cloudAgentDecode(current) + if err != nil { + return err + } + if fresh.ActiveTaskID != taskID { + return nil + } + fresh.ActiveTaskID = "" + fresh.ActiveTextDraft = "" + if reasoning != "" { + fresh.event(runID, "reasoning_message", map[string]any{"messageId": taskID + ":reasoning", "text": truncateRunes(reasoning, 8000)}) + } + if text != "" { + fresh.event(runID, "assistant_message", map[string]any{"messageId": taskID, "text": text}) + } + return cloudAgentSave(current, &fresh) + }) + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + } + return repository.ErrCreationConflict +} + +func (s *Service) waitCloudAgentTask(ctx context.Context, taskID string) (*model.Task, error) { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + task, err := s.repo.Task(taskID) + if err != nil { + return nil, err + } + if cloudAgentTaskTerminal(task.Status) { + if task.Status != model.TaskStatusSucceeded { + // 与画布节点同一套失败分类(网络/审核/存储/HTTP 状态/供应商原因), + // 原始错误保留在任务中心诊断里,不直接抛给用户。 + return nil, BadAuthRequest(userFacingTaskFailure(task)) + } + return task, nil + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + } + } +} diff --git a/backend/internal/app/cloud_agent_pi_request.go b/backend/internal/app/cloud_agent_pi_request.go new file mode 100644 index 000000000..d4cea9341 --- /dev/null +++ b/backend/internal/app/cloud_agent_pi_request.go @@ -0,0 +1,274 @@ +// 组装交给 Agent 运行时的请求:工具清单、技能、画像、记忆、功能开关、权限与模型配置。 +// +// 权限由服务端按画布归属计算后下发,运行时只能在这个范围内调用工具。 + +package app + +import ( + "context" + "encoding/json" + "fmt" + "log" + "log/slog" + "path/filepath" +) + +// EnhancedPiRequestParams 增强的 Pi 请求参数 +type EnhancedPiRequestParams struct { + UserID string + RunID string + CanvasID string + FocusNodeIDs []string + Prompt string + SystemPrompt string + ModelID string + RuntimeState *cloudAgentRuntime + Canonical *canonicalAgentRequest + SessionFile string + SessionJSONL string + SessionDir string +} + +// buildEnhancedPiRequest 构建增强的 Pi 请求(核心方法) +func (s *Service) buildEnhancedPiRequest(ctx context.Context, params EnhancedPiRequestParams) (cloudAgentPiProcessRequest, error) { + slog.Debug("agent building request", "canvas", params.CanvasID) + + // 1. 构建工具列表(包括画布工具) + tools := s.buildCompletePiTools(params.Canonical.Tools) + + // 2. 构建 Skills 配置 + skillPaths := s.buildSkillPaths(params.RuntimeState.Skills) + enabledSkills := s.buildSkillManifests(params.RuntimeState.Skills) + + slog.Debug("agent enabled skills", "count", len(enabledSkills)) + + // 3. 构建 Profile 配置 + profile := s.buildProfileConfig(params.RuntimeState.Profile) + + // 4. 构建 Memory 配置 + memory := s.buildMemoryConfig(params.UserID, params.CanvasID) + + // 5. 构建画布智能上下文(核心创新) + canvasIntelligence := s.NewCanvasIntelligence() + enhancedCanvas, err := canvasIntelligence.BuildEnhancedCanvasContext(ctx, params.UserID, params.CanvasID, params.FocusNodeIDs) + if err != nil { + return cloudAgentPiProcessRequest{}, fmt.Errorf("build canvas intelligence: %w", err) + } + + canvasData, err := s.marshalEnhancedCanvas(enhancedCanvas) + if err != nil { + return cloudAgentPiProcessRequest{}, fmt.Errorf("marshal canvas: %w", err) + } + + slog.Debug("agent canvas intelligence", "nodes", + enhancedCanvas.Snapshot.TotalNodes, + "focus", len(enhancedCanvas.Snapshot.FocusNodes), + "relationships", len(enhancedCanvas.Snapshot.Relationships), + "clusters", len(enhancedCanvas.Snapshot.Layout.Clusters)) + + // 6. 构建 Features 配置 + features := s.buildFeaturesConfig(params.RuntimeState) + + // 7. 构建压缩策略 + compaction := s.buildCompactionStrategy(params.UserID, params.CanvasID) + + // 8. 构建权限配置 + permissions := s.buildPermissionsConfig(params.UserID, params.CanvasID) + + // 9. 构建模型配置 + modelConfig := s.buildModelConfig(params.ModelID, params.RuntimeState) + + // 10. 组装完整请求 + request := cloudAgentPiProcessRequest{ + // 会话信息 + SessionFile: params.SessionFile, + SessionJSONL: params.SessionJSONL, + SessionDir: params.SessionDir, + Cwd: s.dataDir, + AgentDir: params.SessionDir, + + // 标识信息 + SessionId: params.RunID, + UserId: params.UserID, + CanvasId: params.CanvasID, + RunId: params.RunID, + + // 对话内容 + Prompt: params.Prompt, + SystemPrompt: params.SystemPrompt, + + // 核心增强:完整的上下文传递 + SkillPaths: skillPaths, + EnabledSkills: enabledSkills, + Profile: profile, + Memory: memory, + Canvas: canvasData, + Features: features, + + // 配置 + Tools: tools, + Compaction: compaction, + Permissions: permissions, + Model: modelConfig, + } + + slog.Debug("agent request built", "canvas", params.CanvasID) + return request, nil +} + +// buildCompletePiTools 返回本轮授权的平台工具(与 Go 业务执行器同一份定义)。 +func (s *Service) buildCompletePiTools(canonicalTools []map[string]interface{}) []map[string]any { + return piToolDefinitions(canonicalTools) +} + +// buildProfileConfig 构建 Profile 配置 +func (s *Service) buildProfileConfig(profile cloudAgentProfileSnapshot) map[string]any { + layers := make([]map[string]any, 0, len(profile.Layers)) + for i, layer := range profile.Layers { + layers = append(layers, map[string]any{ + "scope": layer.Scope, + "content": layer.Content, + "priority": i, + "hash": hashContentSHA256(layer.Content), + }) + } + + return map[string]any{ + "revision": profile.Revision, + "hash": profile.Hash, + "layers": layers, + "enabled": true, + } +} + +// buildMemoryConfig 构建 Memory 配置 +func (s *Service) buildMemoryConfig(userID, canvasID string) map[string]any { + memoryDir := filepath.Join(s.dataDir, "memory", userID) + canvasMemoryDir := filepath.Join(memoryDir, canvasID) + + return map[string]any{ + "enabled": true, + "storePath": memoryDir, + "canvasStorePath": canvasMemoryDir, + "userId": userID, + "canvasId": canvasID, + "maxEntries": 1000, + "indexingEnabled": true, + "searchEnabled": true, + "autoSaveInterval": 60, // seconds + "compressionEnabled": true, + } +} + +// buildFeaturesConfig 构建 Features 配置 +func (s *Service) buildFeaturesConfig(state *cloudAgentRuntime) map[string]any { + return map[string]any{ + "planningEnabled": true, + "formsEnabled": true, + "memoryEnabled": true, + "skillsEnabled": len(state.Skills) > 0, + "canvasIntelligence": true, + "approvalRequired": []string{"canvas_node_delete", "canvas_bulk_operation"}, + "autoSave": true, + "collaborationMode": "multi-user", + "versionControl": true, + } +} + +// buildPermissionsConfig 构建权限配置 +func (s *Service) buildPermissionsConfig(userID, canvasID string) map[string]any { + // 从数据库读取实际权限 + canvas, err := s.repo.GetCanvas(userID, canvasID) + if err != nil { + log.Printf("[Agent] failed to get canvas for permissions: %v", err) + // 返回最小权限集 + return map[string]any{ + "canReadCanvas": true, + "canWriteCanvas": false, + "canDeleteNodes": false, + "canCreateNodes": false, + "canMoveNodes": false, + "canDuplicateNodes": false, + "canManageRelations": false, + "canInviteUsers": false, + "canExportCanvas": true, + "maxTokenBudget": 200000, + "maxSteps": 50, + } + } + + // 检查用户是否是画布所有者 + isOwner := canvas.UserID == userID + + // 检查协作权限 + canWrite := isOwner + canDelete := isOwner + canInvite := isOwner + + if canvas.Metadata != nil { + if collaborators, ok := canvas.Metadata["collaborators"].([]any); ok { + for _, collab := range collaborators { + if collabMap, ok := collab.(map[string]any); ok { + if collabUserID, _ := collabMap["userId"].(string); collabUserID == userID { + role, _ := collabMap["role"].(string) + switch role { + case "admin": + canWrite = true + canDelete = true + canInvite = true + case "editor": + canWrite = true + case "viewer": + // 只读权限 + } + } + } + } + } + } + + return map[string]any{ + "canReadCanvas": true, + "canWriteCanvas": canWrite, + "canDeleteNodes": canDelete, + "canCreateNodes": canWrite, + "canMoveNodes": canWrite, + "canDuplicateNodes": canWrite, + "canManageRelations": canWrite, + "canInviteUsers": canInvite, + "canExportCanvas": true, + "maxTokenBudget": 200000, + "maxSteps": 50, + } +} + +// buildModelConfig 构建模型配置 +func (s *Service) buildModelConfig(modelID string, state *cloudAgentRuntime) map[string]any { + return map[string]any{ + "id": modelID, + "name": modelID, + "reasoning": cloudAgentReasoningEnabled(state.Policy.ReasoningMode), + "input": []string{"text", "image"}, + "contextWindow": 200000, + "maxTokens": 8192, + "provider": state.Request.ChannelID, + "switchable": true, + "temperature": 0.7, + "topP": 0.9, + } +} + +// marshalEnhancedCanvas 序列化增强画布 +func (s *Service) marshalEnhancedCanvas(canvas *EnhancedCanvasContext) (map[string]any, error) { + data, err := json.Marshal(canvas) + if err != nil { + return nil, err + } + + var result map[string]any + if err := json.Unmarshal(data, &result); err != nil { + return nil, err + } + + return result, nil +} diff --git a/backend/internal/app/cloud_agent_runtime.go b/backend/internal/app/cloud_agent_runtime.go index 3cbb8c89e..508ef018d 100644 --- a/backend/internal/app/cloud_agent_runtime.go +++ b/backend/internal/app/cloud_agent_runtime.go @@ -1,23 +1,13 @@ package app import ( - "context" "encoding/json" "errors" "fmt" - "log" - "math" - "sort" - "strings" "time" - "unicode/utf8" - "gorm.io/gorm" "infinite-canvas/backend/internal/agentcontext" - "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/prompts" - "infinite-canvas/backend/internal/repository" ) // A deterministic checkpoint failure must not be retried forever like a transient DB error. @@ -69,6 +59,7 @@ type CloudAgentEvent struct { Payload map[string]any `json:"payload"` CreatedAt time.Time `json:"createdAt"` } + type cloudAgentCall struct { ID string `json:"id"` Function struct { @@ -123,6 +114,7 @@ type cloudAgentApproval struct { Decision string `json:"decision,omitempty"` Reason string `json:"reason,omitempty"` } + type cloudAgentRuntime struct { RuntimeRunID string `json:"-"` Request CloudAgentRequest `json:"request"` @@ -329,2387 +321,9 @@ func (s *Service) ensureCloudAgentExecution(task *model.Task, initial cloudAgent } return s.repo.EnsureCloudAgent(run) } + func (state *cloudAgentRuntime) event(id, kind string, payload map[string]any) { // 序号接在"已载入窗口 + 窗口之前已入库条数"之后,与 EventSeqBase 的不变量一致。 seq := state.EventSeqBase + len(state.Events) + 1 state.Events = append(state.Events, CloudAgentEvent{EventID: fmt.Sprintf("%s:%d", id, seq), RunID: id, Seq: seq, Type: kind, Payload: payload, CreatedAt: time.Now()}) } -func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) { - var state cloudAgentRuntime - if run == nil || strings.TrimSpace(run.StateJSON) == "" { - return state, errors.New("Agent runtime state is empty") - } - if err := json.Unmarshal([]byte(run.StateJSON), &state); err != nil { - return state, fmt.Errorf("decode Agent runtime state: %w", err) - } - state.RuntimeRunID = run.ID - if run.CheckpointVersion >= 2 { - if len(run.Transcript) != run.MessageCount { - return state, errors.New("Agent execution transcript is incomplete") - } - // 事件只载入最近一窗(repository.CloudAgentJournalWindow),所以这里校验的是 - // "窗口与水位自洽"而不是全量条数: - // - 窗口必须正好结束在水位上(最后一条 seq == EventCount),否则说明有行没读到; - // - 窗口为空时水位只能是 0,否则同样说明行缺失(不能把"一行都没有"当成空日志); - // - EventSeqBase = 水位 - 窗口条数,即窗口之前已入库的条数。 - if len(run.Journal) == 0 && run.EventCount != 0 { - return state, errors.New("Agent execution journal is incomplete") - } - state.EventSeqBase = run.EventCount - len(run.Journal) - if state.EventSeqBase < 0 { - return state, errors.New("Agent execution journal watermark is invalid") - } - state.Events = make([]CloudAgentEvent, 0, len(run.Journal)) - state.Canonical.Messages, state.TextHistory = nil, nil - for _, record := range run.Journal { - expected := state.EventSeqBase + len(state.Events) + 1 - if record.Sequence != expected { - return state, errors.New("Agent event sequence is incomplete") - } - var event CloudAgentEvent - if err := json.Unmarshal([]byte(record.EventJSON), &event); err != nil { - return state, fmt.Errorf("decode Agent event: %w", err) - } - if event.Seq != record.Sequence || event.RunID != run.ID || event.EventID != fmt.Sprintf("%s:%d", run.ID, record.Sequence) { - return state, errors.New("Agent event identity is invalid") - } - state.Events = append(state.Events, event) - } - if len(state.Events) > 0 && state.Events[len(state.Events)-1].Seq != run.EventCount { - return state, errors.New("Agent execution journal is incomplete") - } - for _, record := range run.Transcript { - switch record.Kind { - case "canonical": - var message map[string]any - if err := json.Unmarshal([]byte(record.MessageJSON), &message); err != nil { - return state, fmt.Errorf("decode Agent message: %w", err) - } - if record.Sequence != len(state.Canonical.Messages)+1 { - return state, errors.New("Agent message sequence is incomplete") - } - state.Canonical.Messages = append(state.Canonical.Messages, message) - case "history": - var message providerTextMessage - if err := json.Unmarshal([]byte(record.MessageJSON), &message); err != nil { - return state, fmt.Errorf("decode Agent history: %w", err) - } - if record.Sequence != len(state.TextHistory)+1 { - return state, errors.New("Agent history sequence is incomplete") - } - state.TextHistory = append(state.TextHistory, message) - default: - return state, errors.New("Agent message kind is unsupported") - } - } - } - if err := validateCloudAgentRuntime(run, &state); err != nil { - return state, err - } - return state, nil -} - -// cloudAgentDecodeForExecution is the only decoder for paths that may resume -// model/tool execution. Historical reads intentionally use cloudAgentDecode: -// a completed turn keeps its frozen policy and capability snapshot and remains -// valid conversation context after the current runtime contract advances. -func cloudAgentDecodeForExecution(run *model.CloudAgentExecution) (cloudAgentRuntime, error) { - state, err := cloudAgentDecode(run) - if err != nil { - return state, WrapAppError(409, "Agent 运行记录无法安全恢复;请新建一轮消息", err) - } - if err := validateCloudAgentPolicySnapshot(state.Policy); err != nil { - return state, WrapAppError(409, "Agent 运行使用旧版执行合同,无法继续原运行;请新建一轮消息", err) - } - return state, nil -} - -func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgentRuntime) error { - if run == nil || state == nil { - return errors.New("Agent runtime state is missing") - } - if run.ID == "" { - // Package-level tool tests use an in-memory runtime without a durable - // execution identity. Durable rows are always validated below. - return nil - } - if state.Request.CanvasID == "" || state.Request.Prompt == "" || state.Request.PermissionMode == "" { - return errors.New("Agent runtime request is incomplete") - } - if err := validateCloudAgentRequest(&state.Request); err != nil { - return fmt.Errorf("invalid Agent runtime request: %w", err) - } - if err := validateCloudAgentPolicySnapshotStructure(state.Policy); err != nil { - return err - } - if err := validateCloudAgentProfileSnapshot(state.Profile, state.Policy); err != nil { - return err - } - for scope, read := range state.ProfileReads { - found := false - for _, layer := range state.Profile.Layers { - found = found || layer.Scope == scope - } - if !read || !found { - return errors.New("Agent runtime profile read history is invalid") - } - } - if state.Step < 0 || state.Generations < 0 || state.VideoSeconds < 0 { - return errors.New("Agent runtime budget or step is invalid") - } - if state.ConfirmationRounds < 0 || state.ConfirmationRounds > cloudAgentMaxConfirmationRounds { - return errors.New("Agent runtime confirmation round is invalid") - } - if len(state.ConfirmationFingerprints) > cloudAgentMaxConfirmationRounds { - return errors.New("Agent runtime confirmation fingerprint history is invalid") - } - seenConfirmationPoints := make(map[string]struct{}, len(state.ConfirmationFingerprints)) - for _, fingerprint := range state.ConfirmationFingerprints { - if !cloudAgentSHA256(fingerprint) { - return errors.New("Agent runtime confirmation fingerprint is invalid") - } - if _, exists := seenConfirmationPoints[fingerprint]; exists { - return errors.New("Agent runtime confirmation fingerprint history contains duplicates") - } - seenConfirmationPoints[fingerprint] = struct{}{} - } - if state.PendingConfirmationFingerprint != "" { - if !cloudAgentSHA256(state.PendingConfirmationFingerprint) { - return errors.New("Agent runtime pending confirmation fingerprint is invalid") - } - if _, exists := seenConfirmationPoints[state.PendingConfirmationFingerprint]; !exists { - return errors.New("Agent runtime pending confirmation fingerprint is not recorded") - } - } - if state.ImageInspectCalls < 0 { - return errors.New("Agent runtime image inspection budget is invalid") - } - if state.ReadToolCalls < 0 { - return errors.New("Agent runtime read tool budget is invalid") - } - for nodeID, count := range state.ImageInspectCounts { - if strings.TrimSpace(nodeID) == "" || count < 0 { - return errors.New("Agent runtime image inspection counts are invalid") - } - } - for key, count := range state.ImageInspectionReads { - if strings.TrimSpace(key) == "" || count < 0 { - return errors.New("Agent runtime image inspection read history is invalid") - } - } - for key, count := range state.ToolReadReplays { - if strings.TrimSpace(key) == "" || count < 0 { - return errors.New("Agent runtime read replay history is invalid") - } - } - if (state.Request.Budget.MaxGenerationTasks > 0 && state.Generations > state.Request.Budget.MaxGenerationTasks) || (state.Request.Budget.MaxVideoSeconds > 0 && state.VideoSeconds > state.Request.Budget.MaxVideoSeconds) { - return errors.New("Agent runtime generation budget is invalid") - } - if state.CallIndex < 0 || state.CallIndex > len(state.Calls) || len(state.Calls) > cloudAgentMaxToolCalls { - return errors.New("Agent runtime call cursor is invalid") - } - for toolName, repair := range state.ToolRepairs { - if toolName == "" || utf8.RuneCountInString(toolName) > 80 || repair.Attempt < 1 || repair.Attempt > cloudAgentToolAttemptLimit || repair.GroupID == "" { - return errors.New("Agent runtime tool repair state is invalid") - } - if err := validateCloudAgentID(repair.GroupID, "工具纠错组 ID", 240); err != nil { - return err - } - } - if state.ActiveTaskID != "" && state.MediaTaskID != "" { - return errors.New("Agent runtime has multiple active tasks") - } - if state.HistoryIncludesCurrent && len(state.Canonical.Messages) < len(state.TextHistory) { - return errors.New("Agent compacted history exceeds canonical transcript") - } - if len(state.TaskIDs) == 0 { - return errors.New("Agent runtime task history is invalid") - } - seenTasks := make(map[string]struct{}, len(state.TaskIDs)) - for _, taskID := range state.TaskIDs { - if err := validateCloudAgentID(taskID, "任务 ID", 80); err != nil { - return err - } - if _, exists := seenTasks[taskID]; exists { - return errors.New("Agent runtime task history contains duplicates") - } - seenTasks[taskID] = struct{}{} - } - if state.ActiveTaskID != "" && !cloudAgentContainsString(state.TaskIDs, state.ActiveTaskID) { - return errors.New("Agent runtime active task is not in task history") - } - if state.MediaTaskID != "" { - if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || (state.Calls[state.CallIndex].Function.Name != "generate_media" && state.Calls[state.CallIndex].Function.Name != "image_layer_split") { - return errors.New("Agent runtime media task is not attached to current call") - } - } - if state.AutoPreparedMedia != nil { - if state.AutoPreparedCallHash == "" || state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { - return errors.New("Agent runtime auto media preparation is invalid") - } - current := state.Calls[state.CallIndex] - if current.Function.Name != "generate_media" && current.Function.Name != "image_layer_split" { - return errors.New("Agent runtime auto media preparation is not attached to a media call") - } - if state.AutoPreparedCallHash != cloudAgentApprovalCallHash(current) { - return errors.New("Agent runtime auto media preparation does not match current call") - } - } - if state.Decisions == nil || state.Events == nil { - return errors.New("Agent runtime maps are missing") - } - if state.EventSeqBase < 0 { - return errors.New("Agent runtime event watermark is invalid") - } - // 内存里只有"最近一窗 + 本次转移新产生的部分",超过健全上限说明窗口没有按 - // CloudAgentJournalWindow 载入(或序号基准算错),此时不能继续推进。 - if len(state.Events) > cloudAgentEventWindowSanityLimit { - return errors.New("Agent runtime event window is too large") - } - for index, event := range state.Events { - if event.RunID != run.ID || event.Seq != state.EventSeqBase+index+1 || event.EventID == "" || event.Type == "" || event.Payload == nil || event.CreatedAt.IsZero() { - return errors.New("Agent runtime event history is invalid") - } - if err := validateCloudAgentID(event.EventID, "事件 ID", 240); err != nil { - return err - } - raw, err := json.Marshal(event.Payload) - if err != nil || len(raw) > 128<<10 { - return errors.New("Agent runtime event payload is too large") - } - } - for _, call := range state.Calls { - if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || call.Function.Name == "" || utf8.RuneCountInString(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) { - return errors.New("Agent runtime tool call is invalid") - } - if len(call.Function.Arguments) > 32000 { - return errors.New("Agent runtime tool arguments are too large") - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil { - return errors.New("Agent runtime tool arguments are invalid") - } - } - if state.Approval != nil { - if err := validateCloudAgentID(state.Approval.ID, "审批 ID", 200); err != nil || state.CallIndex >= len(state.Calls) { - return errors.New("Agent runtime approval is invalid") - } - current := state.Calls[state.CallIndex] - if state.Approval.Call.ID != current.ID || state.Approval.Call.Function.Name != current.Function.Name || state.Approval.Call.Function.Arguments != current.Function.Arguments { - return errors.New("Agent runtime approval does not match current call") - } - if state.Approval.Decision != "" && state.Approval.Decision != "approve" && state.Approval.Decision != "reject" { - return errors.New("Agent runtime approval decision is invalid") - } - } - if state.CallIndex == len(state.Calls) && state.Approval != nil { - return errors.New("Agent runtime has approval without a pending call") - } - return nil -} - -func validateCloudAgentPolicySnapshot(snapshot cloudAgentPolicySnapshot) error { - if err := validateCloudAgentPolicySnapshotStructure(snapshot); err != nil { - return err - } - if snapshot.CompilerVersion != cloudAgentCompilerVersion { - return errors.New("Agent runtime policy compiler is unsupported") - } - if snapshot.CapabilitySetVersion != cloudAgentCapabilitySetVersion { - return errors.New("Agent runtime capability contract is unsupported") - } - if snapshot.ReasoningMode != "off" && snapshot.ReasoningMode != "auto" && snapshot.ReasoningMode != "deep" { - return errors.New("Agent runtime reasoning mode is invalid") - } - system, media, err := prompts.LoadAgentPolicies() - if err != nil { - return fmt.Errorf("load Agent runtime policies: %w", err) - } - if snapshot.SystemPolicyID != system.ID || snapshot.SystemPolicyVersion != system.Version || snapshot.MediaPolicyID != media.ID || snapshot.MediaPolicyVersion != media.Version { - return errors.New("Agent runtime policy version is unsupported") - } - // An existing run keeps its admitted prompt and tool schema. Never resume it - // against changed policies or capabilities under an unchanged version label. - if snapshot.SystemPolicyHash != system.Hash || snapshot.MediaPolicyHash != media.Hash || snapshot.CapabilitySetHash != cloudAgentCapabilitySetHash() { - return errors.New("Agent runtime policy or capability contract has changed") - } - return nil -} - -// validateCloudAgentPolicySnapshotStructure validates the frozen historical -// record without treating the current binary as its authorization source. -// Version/hash equality with the current runtime is checked separately, only -// when an old run is about to execute again. -func validateCloudAgentPolicySnapshotStructure(snapshot cloudAgentPolicySnapshot) error { - for _, field := range []struct { - name string - value string - }{ - {"policy compiler", snapshot.CompilerVersion}, - {"system policy ID", snapshot.SystemPolicyID}, - {"media policy ID", snapshot.MediaPolicyID}, - {"capability set version", snapshot.CapabilitySetVersion}, - } { - if strings.TrimSpace(field.value) == "" || !utf8.ValidString(field.value) || utf8.RuneCountInString(field.value) > 120 { - return fmt.Errorf("Agent runtime %s is invalid", field.name) - } - } - if snapshot.SystemPolicyVersion <= 0 || snapshot.MediaPolicyVersion <= 0 { - return errors.New("Agent runtime policy version is invalid") - } - if snapshot.ReasoningMode != "off" && snapshot.ReasoningMode != "auto" && snapshot.ReasoningMode != "deep" { - return errors.New("Agent runtime reasoning mode is invalid") - } - for _, field := range []struct { - name string - value string - }{ - {"system policy hash", snapshot.SystemPolicyHash}, - {"media policy hash", snapshot.MediaPolicyHash}, - {"capability set hash", snapshot.CapabilitySetHash}, - {"profile revision", snapshot.ProfileRevision}, - {"profile hash", snapshot.ProfileHash}, - } { - if !cloudAgentSHA256(field.value) { - return fmt.Errorf("Agent runtime %s is invalid", field.name) - } - } - return nil -} - -func cloudAgentSHA256(value string) bool { - if len(value) != 64 { - return false - } - for _, r := range value { - if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { - return false - } - } - return true -} - -func cloudAgentContainsString(values []string, target string) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} - -const cloudAgentStateJSONLimit = 512 << 10 - -func cloudAgentCheckpointJSON(state *cloudAgentRuntime) ([]byte, error) { - checkpoint := *state - checkpoint.Canonical.Messages = nil - checkpoint.TextHistory = nil - checkpoint.Events = nil - return json.Marshal(checkpoint) -} - -// cloudAgentPruneReadCacheForCheckpoint is a last-resort durability guard. -// ToolReadResults is a replay cache: losing one entry means a later read may -// execute again, but allowing that cache to make the whole runtime impossible -// to checkpoint loses the run and prevents context compaction from starting. -// Remove the largest entries first so a single oversized canvas/skill result -// cannot strand the run above the 512 KiB state limit. -func cloudAgentPruneReadCacheForCheckpoint(state *cloudAgentRuntime, limit int) (removed int) { - if state == nil || len(state.ToolReadResults) == 0 || limit <= 0 { - return 0 - } - raw, err := cloudAgentCheckpointJSON(state) - if err != nil || len(raw) <= limit { - return 0 - } - type candidate struct { - key string - bytes int - inContext bool - error bool - } - candidates := make([]candidate, 0, len(state.ToolReadResults)) - for key, cached := range state.ToolReadResults { - entryBytes, _ := json.Marshal(cached) - candidates = append(candidates, candidate{ - key: key, - bytes: len(entryBytes), - inContext: cloudAgentReadResultInContext(state, cached.Result), - error: cached.Error != "", - }) - } - // Prefer dropping errors and entries already absent from the current - // transcript. Keep results currently visible to the model for as long as - // possible because compaction may need them for a replay after eviction. - sort.Slice(candidates, func(i, j int) bool { - if candidates[i].inContext != candidates[j].inContext { - return !candidates[i].inContext - } - if candidates[i].error != candidates[j].error { - return candidates[i].error - } - if candidates[i].bytes != candidates[j].bytes { - return candidates[i].bytes > candidates[j].bytes - } - return candidates[i].key < candidates[j].key - }) - for _, item := range candidates { - if raw, err = cloudAgentCheckpointJSON(state); err != nil || len(raw) <= limit { - break - } - delete(state.ToolReadResults, item.key) - delete(state.ToolReadReplays, item.key) - removed++ - } - if len(state.ToolReadResults) == 0 { - state.ToolReadResults = nil - } - if len(state.ToolReadReplays) == 0 { - state.ToolReadReplays = nil - } - return removed -} - -func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) error { - if run == nil || state == nil { - return errors.New("Agent runtime state is missing") - } - // 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。 - // 上游每一步都会重新读取历史里的图片并按视觉 token 计费,保留整段历史既贵又没有新信息。 - if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, nil); changed && run.ID != "" { - state.event(run.ID, "context_images_pruned", map[string]any{ - "prunedImages": pruned, "retentionRounds": cloudAgentImageRetentionRounds, - "text": "已把超出保留轮次的看图结果移出模型上下文(保留文字回执与 nodeId)", - }) - } - if run.ID != "" { - if err := validateCloudAgentRuntime(run, state); err != nil { - return cloudAgentCheckpointFailure("runtime validation", err) - } - for index, event := range state.Events { - sequence := state.EventSeqBase + index + 1 - if event.Seq != sequence || event.RunID != run.ID || event.EventID != fmt.Sprintf("%s:%d", run.ID, sequence) { - return cloudAgentCheckpointFailure("event identity", errors.New("Agent event sequence or identity is invalid")) - } - } - } - // The canonical transcript and event journal are persisted separately. The - // bounded StateJSON therefore contains only runtime metadata and replay - // caches, and the latter must never be allowed to block a checkpoint. - raw, err := cloudAgentCheckpointJSON(state) - if err != nil { - return cloudAgentCheckpointFailure("state encode", err) - } - if len(raw) > cloudAgentStateJSONLimit { - cloudAgentPruneReadCacheForCheckpoint(state, cloudAgentStateJSONLimit) - raw, err = cloudAgentCheckpointJSON(state) - if err != nil { - return cloudAgentCheckpointFailure("state encode after cache pruning", err) - } - } - if len(raw) > cloudAgentStateJSONLimit { - return cloudAgentCheckpointFailure("state size", fmt.Errorf("Agent 状态超过 512KB 上限(%d bytes)", len(raw))) - } - run.CanvasID, run.ActiveTaskID, run.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID - run.ParentID = state.ParentID - if run.Title == "" { - run.Title = truncateRunes(state.Request.Prompt, 80) - } - // 事件按"窗口 + 水位"落库:run.Journal 只是内存窗口的映射(窗口之外的历史仍在 - // 事件表里),写入侧(repository.MutateCloudAgent)只追加 seq > 旧水位的行, - // 因此窗口左移不会丢历史。 - run.Journal = make([]model.CloudAgentEventRecord, 0, len(state.Events)) - for _, event := range state.Events { - body, err := json.Marshal(event) - if err != nil { - return cloudAgentCheckpointFailure("event encode", err) - } - run.Journal = append(run.Journal, model.CloudAgentEventRecord{RunID: run.ID, UserID: run.UserID, Sequence: event.Seq, EventJSON: string(body), CreatedAt: event.CreatedAt}) - } - run.Transcript = make([]model.CloudAgentMessageRecord, 0, len(state.Canonical.Messages)+len(state.TextHistory)) - for index, message := range state.Canonical.Messages { - body, err := json.Marshal(message) - if err != nil { - return cloudAgentCheckpointFailure("canonical message encode", err) - } - run.Transcript = append(run.Transcript, model.CloudAgentMessageRecord{RunID: run.ID, UserID: run.UserID, Kind: "canonical", Sequence: index + 1, MessageJSON: string(body)}) - } - for index, message := range state.TextHistory { - body, err := json.Marshal(message) - if err != nil { - return cloudAgentCheckpointFailure("history encode", err) - } - run.Transcript = append(run.Transcript, model.CloudAgentMessageRecord{RunID: run.ID, UserID: run.UserID, Kind: "history", Sequence: index + 1, MessageJSON: string(body)}) - } - // 水位 = 窗口之前已入库的条数 + 本次载入/新增的窗口条数,必须与事件表里的 - // 最大 seq 一致,否则下一次载入的窗口与水位会对不上。 - run.CheckpointVersion, run.EventCount, run.MessageCount = 2, state.EventSeqBase+len(state.Events), len(run.Transcript) - run.StateJSON = string(raw) - return nil -} -func (s *Service) cloudAgentExecutionOutput(task *model.Task, initial cloudAgentState, options ...CloudAgentRunViewOptions) (*CloudAgentRun, error) { - run, err := s.repo.CloudAgent(task.UserID, task.ID) - if err != nil { - return nil, err - } - state, stateErr := cloudAgentDecode(run) - if stateErr != nil { - // A terminal failed run must remain readable even if its durable runtime - // blob was damaged. Do not invent permissions or approval state; expose - // only the identity available from the original task input. - state = cloudAgentRuntime{Request: initial.Request, ParentID: initial.ParentID, CreativeAnchor: initial.CreativeAnchor, Skills: initial.Skills, Profile: initial.Profile, TaskIDs: []string{task.ID}, Events: []CloudAgentEvent{}} - } - out := agentRunOutput(task, initial) - out.Status = run.Status - out.Revision, out.CleanupPending, out.FailureMessage = run.Revision, run.CleanupPending, run.FailureMessage - out.UpdatedAt = run.UpdatedAt - view := CloudAgentRunViewOptions{} - if len(options) > 0 { - view = options[0] - } - // 事件已全量落库,运行详情只返回一页:默认是尾部窗口,sinceSeq 只取增量。 - // 四个位置字段与 events 一起返回,客户端据此判断"是否还有更早的记录"。 - out.Events = s.cloudAgentRunEventsForView(task.UserID, run, &state, view.SinceSeq, view.EventLimit) - // EventSeqBase 必须与真正返回的这一页对齐(eventLimit 把它收窄时也一样), - // 不变量 events[i].seq == eventSeqBase + i + 1 才成立。页为空时退回窗口水位: - // 那时没有"首条事件",但仍然要能说明窗口在整条日志里的位置。 - out.EventSeqBase = state.EventSeqBase - if len(out.Events) > 0 { - out.EventSeqBase = out.Events[0].Seq - 1 - } - out.EventCount = s.cloudAgentRunEventCount(task.UserID, run, &state) - if len(out.Events) > 0 { - out.LatestSeq = out.Events[len(out.Events)-1].Seq - } - if view.SinceSeq == 0 && len(out.Events) < out.EventCount { - out.EventsTruncated = true - } - out.Approval = state.Approval - if cloudAgentRunTerminal(run.Status) { - out.Approval = nil - } - out.Step = state.Step - if stateErr == nil && state.ActiveTaskID != "" && (run.Status == "running" || run.Status == "queued") { - active, err := s.repo.TaskForUser(task.UserID, state.ActiveTaskID) - if err != nil { - return nil, err - } - if active.TextDraft != "" { - out.ActiveMessage = map[string]string{"messageId": active.ID, "text": active.TextDraft} - } - } - out.Skills = make([]cloudAgentSkill, 0, len(state.Skills)) - for _, skill := range state.Skills { - skill.Instruction = "" - skill.Files = nil - out.Skills = append(out.Skills, skill) - } - orders, err := s.repo.BillingOrdersByTaskIDs(task.UserID, state.TaskIDs) - if err != nil { - return nil, err - } - for _, order := range orders { - // AmountMicrocredits is the reservation/quote, not necessarily the amount - // finally charged. A run can reserve 100000 microcredits and settle at - // 100000 microcredits (= 0.1 credits), or be refunded altogether. Never - // expose the reservation as spend, otherwise the Agent claims a charge - // that did not happen and budget/usage copy diverges from billing. - if order.Status == model.BillingStatusSettled { - out.SpentCredits += float64(order.ActualAmountMicrocredits) / float64(CreditScale) - } - } - return out, nil -} - -// Runs one bounded transition at a time; no model HTTP call or approval wait holds a DB lock. -func (s *Service) advanceCloudAgentByID(userID, id string) error { - // Do not decode the task input/runtime before checking for an existing - // execution. A damaged runtime must be terminally recoverable, not - // accidentally replaced by a fresh execution row. - task, err := s.repo.TaskForUser(userID, id) - if err != nil { - return err - } - if task.Operation != cloudAgentOperation { - return kernel.NotFound("Agent 运行不存在") - } - run, lookupErr := s.repo.CloudAgent(userID, id) - if errors.Is(lookupErr, gorm.ErrRecordNotFound) { - _, initial, taskErr := s.cloudAgentTask(userID, id) - if taskErr != nil { - return taskErr - } - if err := s.ensureCloudAgentExecution(task, initial); err != nil { - return err - } - run, lookupErr = s.repo.CloudAgent(userID, id) - } - if lookupErr != nil { - return lookupErr - } - return s.advanceCloudAgent(run) -} -func (s *Service) advanceCloudAgents() { - s.agentSchedulerMu.Lock() - defer s.agentSchedulerMu.Unlock() - roots, err := s.repo.CloudAgentRoots() - if err != nil { - log.Printf("agent recovery: %v", err) - return - } - for _, task := range roots { - _, state, e := s.cloudAgentTask(task.UserID, task.ID) - if e == nil { - e = s.ensureCloudAgentExecution(&task, state) - } - if e != nil { - log.Printf("agent recovery %s: %v", task.ID, e) - } - } - runs, err := s.repo.ActiveCloudAgentsAfter(s.agentSchedulerCursor, 50) - if err == nil && len(runs) == 0 && s.agentSchedulerCursor != "" { - s.agentSchedulerCursor = "" - runs, err = s.repo.ActiveCloudAgentsAfter("", 50) - } - if err != nil { - log.Printf("agent scheduler: %v", err) - return - } - for i := range runs { - s.agentSchedulerCursor = runs[i].ID - if s.terminateStuckCloudAgent(&runs[i]) { - continue - } - err = s.advanceCloudAgent(&runs[i]) - if err == nil { - s.clearCloudAgentSchedulerConflict(runs[i].ID) - continue - } - if errors.Is(err, repository.ErrCreationConflict) { - s.noteCloudAgentSchedulerConflict(runs[i].ID) - continue - } - log.Printf("agent transition %s: %v", runs[i].ID, err) - } -} - -// wakeCloudAgentScheduler lets a completed model step resume its Agent without -// waiting for the periodic recovery scan. The ticker remains authoritative for -// other workers and missed in-process notifications. -func (s *Service) wakeCloudAgentScheduler() { - if s == nil || s.agentSchedulerWake == nil { - return - } - select { - case s.agentSchedulerWake <- struct{}{}: - default: - } -} - -func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) { - defer func() { - if errors.Is(err, errCloudAgentCheckpoint) { - message := "Agent 运行状态保存失败,本轮已停止;已有任务结果保留在任务中心" - var checkpointErr *cloudAgentCheckpointError - if errors.As(err, &checkpointErr) { - log.Printf("agent checkpoint rejected run=%s stage=%s error=%v", run.ID, checkpointErr.Stage, checkpointErr.Err) - if checkpointErr.Stage == "state size" { - message = "Agent 上下文或执行记录超过安全限制,本轮已停止;已有任务结果保留在任务中心" - } - } else { - message = "Agent 运行状态保存失败,本轮已停止;已有任务结果保留在任务中心" - } - err = s.terminateCloudAgent(run, message) - } - }() - if run.CleanupPending { - return s.finishCloudAgentCleanup(context.Background(), run) - } - if run.Status != "running" && run.Status != "queued" { - return nil - } - state, err := cloudAgentDecodeForExecution(run) - if err != nil { - var appErr *AppError - if errors.As(err, &appErr) && appErr != nil { - return s.terminateCloudAgent(run, appErr.Message) - } - return s.terminateCloudAgent(run, "Agent 运行状态损坏,本轮已停止") - } - // 单步边界每次推进都重新解析:管理员改配置后,正在跑的这一轮下一步就用新值。 - state.StepLimits, err = s.cloudAgentStepLimits() - if err != nil { - return err - } - if state.ActiveTaskID != "" { - task, err := s.repo.TaskForUser(run.UserID, state.ActiveTaskID) - if errors.Is(err, gorm.ErrRecordNotFound) { - return s.terminateCloudAgent(run, "Agent 模型任务已不存在,本轮已停止") - } - if err != nil { - return err // Transient database failures must not terminate a live task. - } - // 停在压缩上时,这个在跑的任务就是压缩调用:它的结果只用来生成检查点, - // 不走"正文/工具调用"那套解析,也不会计入步数。 - if state.ContextCompaction != nil { - return s.advanceCloudAgentContextCompaction(run, &state, task) - } - if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning { - // 将已持久化的模型增量转成 Agent 事件;不拆分完整答案伪装成流式。 - if task.TextDraft != state.ActiveTextDraft { - delta := "" - eventType := "assistant_snapshot" - payload := map[string]any{"messageId": task.ID, "text": task.TextDraft, "replace": true} - if strings.HasPrefix(task.TextDraft, state.ActiveTextDraft) { - delta = strings.TrimPrefix(task.TextDraft, state.ActiveTextDraft) - if delta == "" { - return nil - } - eventType = "assistant_delta" - payload = map[string]any{"messageId": task.ID, "text": delta} - } - if err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - state.event(run.ID, eventType, payload) - state.ActiveTextDraft = task.TextDraft - return cloudAgentSave(current, &state) - }); err != nil { - return err - } - } - return nil - } - var result struct { - Text string `json:"text"` - Reasoning string `json:"reasoning"` - ToolCalls []cloudAgentCall `json:"toolCalls"` - Legacy []cloudAgentCall `json:"tool_calls"` - } - if task.Status == model.TaskStatusSucceeded { - if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { - return s.terminateCloudAgent(run, "模型任务结果损坏,本轮已停止") - } - calls := result.ToolCalls - if len(calls) == 0 { - calls = result.Legacy - } - if violation := cloudAgentOutputViolation(result.Text, len(calls)); violation != "" { - return s.correctCloudAgentOutput(run, &state, violation) - } - if err := validateCloudAgentCalls(calls); err != nil { - return s.correctCloudAgentOutput(run, &state, "工具调用无效或重复(callId 不能重复、参数必须是 JSON 对象)") - } - result.ToolCalls = calls - } - if task.Status != model.TaskStatusSucceeded && cloudAgentTruncatedToolArguments(task) { - return s.correctCloudAgentTruncatedCalls(run, &state) - } - if cloudAgentEmptyModelOutput(task) { - if state.EmptyOutputNudged < cloudAgentMaxEmptyOutputNudges { - return s.correctCloudAgentEmptyOutput(run, &state) - } - // 催过仍然空:改为"关思考 + 放大输出预算"重试同一步,而不是把整轮判死。 - if state.EmptyOutputEscalated < cloudAgentMaxEmptyOutputEscalations { - return s.correctCloudAgentEmptyOutputEscalation(run, &state) - } - } - // 单步墙钟到点同样是可恢复失败:关思考重试一次,而不是把整轮判死。 - if cloudAgentStepTimedOut(task) && state.StepTimeoutEscalated < cloudAgentMaxStepTimeoutEscalations { - return s.correctCloudAgentStepTimeout(run, &state) - } - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - if task.Status != model.TaskStatusSucceeded { - current.Status = "failed" - text, reason := cloudAgentModelFailure(task) - current.FailureMessage = truncateRunes(text, 1000) - cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(text, 120), &state) - state.event(run.ID, "run_failed", map[string]any{"text": text, "reason": reason, "taskId": task.ID}) - return cloudAgentSave(current, &state) - } - calls := result.ToolCalls - if result.Reasoning != "" { - state.event(run.ID, "reasoning_message", map[string]any{"messageId": task.ID + ":reasoning", "text": truncateRunes(result.Reasoning, 8000)}) - } - if result.Text != "" { - state.event(run.ID, "assistant_message", map[string]any{"messageId": task.ID, "text": result.Text}) - // 普通正文可能是读取失败或多图混合回答,不能自动归为某张图的视觉事实。 - if len(calls) == 0 { - state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text}) - } - } - state.ActiveTaskID = "" - if len(calls) > 0 || strings.TrimSpace(result.Text) != "" { - state.EmptyOutputNudged = 0 - } - state.Canonical.ToolChoice = "auto" - state.Calls = calls - state.CallIndex = 0 - state.StepSnapshotHash = cloudAgentCaptureStepSnapshotHash(calls) - if len(calls) > 0 { - state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text, "tool_calls": calls}) - } - if len(calls) == 0 { - if len(state.PendingInterjections) > 0 { - if !cloudAgentStepBudgetExhausted(&state) { - return cloudAgentSave(current, &state) - } - cloudAgentDropInterjections(run.ID, "本轮已达到模型调用上限", &state) - } - if !cloudAgentStepBudgetExhausted(&state) && !state.ActionNudged { - if pending := cloudAgentPendingPlanItems(state.Plan); len(pending) > 0 { - state.ActionNudged = true - state.Canonical.Messages = append(state.Canonical.Messages, cloudAgentPlanNudgeMessage(&state, pending[0])) - return cloudAgentSave(current, &state) - } - } - current.Status = "completed" - } - return cloudAgentSave(current, &state) - }) - } - if state.CallIndex < len(state.Calls) { - if handled, err := s.advanceCloudAgentReadBatch(run, &state); handled { - return err - } - return s.advanceCloudAgentTool(run, &state) - } - // 兜底 flush:本批调用都执行完了(无论最后一个调用是不是看图、有没有被中断), - // 缓冲里的图片必须在这里合并成一条 user 消息落到全部 tool 结果之后。少了这一步, - // "看图不是最后一个调用"的批次会把图片永久丢掉,而对应的 tool 回执已经在历史里。 - // 幂等:正常路径(最后一个调用就是看图)已经在 cloudAgentRecordToolResult 里 flush 过。 - cloudAgentFlushPendingImages(&state) - // 已经请求过压缩:这次推进只负责把压缩调用发出去(它不计入步数,见 enqueueCloudAgentTask)。 - if state.ContextCompaction != nil && state.ContextCompaction.Status == "requested" { - return s.enqueueCloudAgentContextCompaction(run, &state) - } - contextBudget := s.cloudAgentContextBudgetForRequest(state.Request) - if compactCloudAgentContext(&state.Canonical, contextBudget) { - // Evicted read bodies must be obtainable again after compaction. - state.ProfileReads = nil - } - if stepLimit := cloudAgentStepLimit(state.Request); stepLimit > 0 && state.Step >= stepLimit { - return s.failCloudAgent(run, &state, fmt.Sprintf("达到 %d 次模型调用上限,本轮已停止", stepLimit)) - } - cloudAgentDrainInterjections(run.ID, &state) - // 上一步的模型调用已经回来,先用它的上游实测用量更新压力锚点,再发下一步: - // 下一步的读数与后面的正文裁剪判定都要用到这份锚点。 - s.recordCloudAgentTokenAnchor(run.UserID, &state) - // 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。 - // 它必须在压缩判定之前跑:图片是最贵的一类内容,先移出再评估 token 压力才有意义。 - if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, nil); changed { - state.event(run.ID, "context_images_pruned", map[string]any{ - "prunedImages": pruned, "retentionRounds": cloudAgentImageRetentionRounds, - "text": "图片裁剪:移出超出保留轮次的看图结果", - }) - } - canonical, contextErr := s.cloudAgentModelContext(run, &state, contextBudget) - if contextErr != nil { - // 真实任务帧与用户要求有时在下一步建模时就超过输入预算; - // 它会先于下方常规 token 判据失败,仍需给语义压缩一次机会。 - if errors.Is(contextErr, errCloudAgentContextOverBudget) { - if requested, err := s.cloudAgentRequestCompaction(run, &state, contextBudget, contextBudget.CompactAtTokens); err != nil || requested { - return err - } - } - var appErr *AppError - if errors.As(contextErr, &appErr) { - return s.failCloudAgent(run, &state, appErr.Message) - } - return contextErr - } - s.attachCloudAgentLessons(&canonical, run.UserID, cloudAgentLessonTaskText(&state)) - references, refErr := s.cloudAgentImageReferences(run.UserID, state.Request, &canonical) - if refErr != nil { - return s.failCloudAgent(run, &state, cloudAgentSafeToolError(refErr)) - } - // 单步输出上限与思考开关:默认按策略给每一步带上界(不带上界时上游按剩余上下文放行, - // 思考模型可以把单步拖到几分钟);空输出升级重试时改为关思考 + 放大预算。 - stepThinking := cloudAgentReasoningEnabled(state.Policy.ReasoningMode) && !state.ForceThinkingOff - stepOutputTokens := cloudAgentStepOutputBudget(state.StepLimits, state.BoostStepOutputBudget) - input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": stepThinking, "maxOutputTokens": stepOutputTokens}} - if len(references) > 0 { - input["referenceImages"] = references - } - tokens, tokenErr := cloudAgentRequestEstimatedTokens(&canonical) - if tokenErr != nil { - return s.failCloudAgent(run, &state, "模型上下文估算失败,请稍后重试") - } - // 超预算不再直接判死:先暂停步进、把历史压成结构化检查点,压完用压缩后的上下文继续本轮。 - // 次数上限(cloudAgentMaxCompactionsPerRun)用完仍超预算时,才回到下面的判死路径。 - // 分工:这是轮内的**语义压缩**(触发者是 token 线,或没配窗口时的字节/条数兜底); - // compactCloudAgentContext 是轮内可重读正文的**就地卸载**,跨轮 textHistory 由 - // trimCloudAgentTextHistory 兜底——三者对象不同、不会互相打架。 - if requested, err := s.cloudAgentRequestCompaction(run, &state, contextBudget, tokens); err != nil || requested { - return err - } - if tokens > contextBudget.InputBudgetTokens { - return s.failCloudAgent(run, &state, cloudAgentContextBudgetMessage(contextBudget)) - } - req := CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_text", Operation: "cloud_agent_step", Prompt: state.Request.Prompt, Model: state.Request.Model, LogicalModelID: state.Request.LogicalModelID, Input: input} - return s.enqueueCloudAgentTask(run, &state, req, nil) -} - -func compactCloudAgentContext(request *canonicalAgentRequest, budget cloudAgentContextBudget) bool { - if request == nil { - return false - } - tokens, err := cloudAgentRequestEstimatedTokens(request) - if err != nil || tokens < budget.CompactAtTokens { - return false - } - // Retain the latest complete tool turn. Never remove call/result envelopes, - // user instructions, call arguments or write receipts to fabricate a summary. - cut := len(request.Messages) - 1 - for cut > 0 && stringField(request.Messages[cut], "role") == "tool" { - cut-- - } - changed := false - for _, message := range request.Messages[:max(0, cut)] { - if stringField(message, "role") != "tool" { - continue - } - var result map[string]any - if json.Unmarshal([]byte(stringField(message, "content")), &result) != nil || result["contextCompacted"] == true { - continue - } - // Only omit re-readable bodies. Preserve IDs, errors, generation status, - // approvals and all other structured facts verbatim. - omitted := false - if nodes, ok := result["nodes"].([]any); ok { - facts := make([]map[string]any, 0, len(nodes)) - for _, value := range nodes { - if node, ok := value.(map[string]any); ok { - fact := map[string]any{"nodeId": node["id"]} - for _, key := range []string{"generation", "generationDraft", "outputReference"} { - if value, exists := node[key]; exists { - fact[key] = value - } - } - if len(fact) > 1 { - facts = append(facts, fact) - } - } - } - if len(facts) > 0 { - result["observedFacts"] = facts - } - } - for _, key := range []string{"content", "nodes"} { - if _, exists := result[key]; exists { - delete(result, key) - omitted = true - } - } - if !omitted { - continue - } - result["contextCompacted"] = true - result["guidance"] = "历史读取正文已移出模型上下文;需要时重新读取。保留的历史状态不是当前状态,也不是执行授权,不得据此重复提交生成。" - body, err := json.Marshal(result) - if err != nil || len(body) >= len(stringField(message, "content")) { - continue - } - message["content"] = string(body) - changed = true - } - return changed -} - -func validateCloudAgentCalls(calls []cloudAgentCall) error { - seen := make(map[string]bool, len(calls)) - for _, call := range calls { - if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || seen[call.ID] || call.Function.Name == "" || len(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) { - return errors.New("invalid Agent tool call") - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil || len(call.Function.Arguments) > 32000 { - return errors.New("invalid Agent tool arguments") - } - seen[call.ID] = true - } - return nil -} - -func (s *Service) terminateCloudAgent(run *model.CloudAgentExecution, message string) error { - if run == nil { - return errors.New(message) - } - if err := s.repo.MarkCloudAgentFailed(run.UserID, run.ID, run.Revision, message); err != nil && !errors.Is(err, repository.ErrCreationConflict) { - return err - } - return nil -} - -func (s *Service) failCloudAgent(run *model.CloudAgentExecution, state *cloudAgentRuntime, message string) error { - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - current.Status = "failed" - current.FailureMessage = truncateRunes(message, 1000) - cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(message, 120), state) - state.event(run.ID, "run_failed", map[string]any{"text": message}) - return cloudAgentSave(current, state) - }) -} - -// failCloudAgentAdmission records deterministic tool admission failures in the -// same checkpoint transaction. Transient repository errors must still escape -// the caller so the scheduler can retry them. -func failCloudAgentAdmission(current *model.CloudAgentExecution, state *cloudAgentRuntime, runID string, err error) error { - message := cloudAgentSafeToolError(err) - current.Status = "failed" - current.FailureMessage = message - state.Approval = nil - state.event(runID, "run_failed", map[string]any{ - "text": message, - "reason": "tool_admission_failed", - }) - return cloudAgentSave(current, state) -} - -// Only expose known failure categories; raw provider errors can contain URLs and credentials. -func cloudAgentModelFailure(task *model.Task) (string, string) { - detail, reason := "模型任务未成功", "model_task_failed" - if diagnostic := taskExecutionDiagnostic(task); diagnostic != nil && diagnostic.Code == string(ReasonUpstreamDNSFailed) { - return "模型服务域名解析失败,请检查渠道域名和后端 DNS 配置;本轮已停止。请在任务中心检查模型任务 " + task.ID, string(ReasonUpstreamDNSFailed) - } - raw := strings.ToLower(task.Error) - switch { - case strings.Contains(task.Error, "没有返回内容"): - // 思考模型的典型失败:整个输出预算被推理吃掉,正文与工具调用皆空。 - detail, reason = "上游连续返回空内容(通常是思考占满输出预算);已自动关思考并放大预算重试仍失败,建议换用非思考模型或调小上下文", "model_empty_output" - case strings.Contains(task.Error, cloudAgentStepTimeoutError): - detail, reason = "单步模型调用超过执行时限仍未返回(长思考或上下文过大时常见);已自动关思考重试仍超时,可在管理端调大 Agent 单步超时", "model_step_timeout" - case strings.Contains(raw, "connection reset by peer"): - detail, reason = "模型连接被对端或中间网络设备重置", "model_connection_reset" - case strings.Contains(raw, "timeout"), strings.Contains(raw, "deadline exceeded"): - detail, reason = "模型请求超时", "model_request_timeout" - case strings.Contains(raw, "connection refused"): - detail, reason = "无法连接模型服务(连接被拒绝)", "model_connection_refused" - } - return detail + ";本轮已停止。请在任务中心检查模型任务 " + task.ID, reason -} - -func cloudAgentSafeToolError(err error) string { - if err == nil { - return "" - } - var readLoopErr *cloudAgentReadLoopError - if errors.As(err, &readLoopErr) { - return readLoopErr.Error() - } - var appErr *AppError - if errors.As(err, &appErr) && appErr != nil { - message := strings.TrimSpace(appErr.Message) - if cloudAgentSafeUserMessage(message) { - return message - } - } - return "工具执行失败,请检查输入或稍后重试" -} - -func cloudAgentSafeUserMessage(message string) bool { - if message == "" || !utf8.ValidString(message) || strings.ContainsAny(message, "\x00\r\n") || utf8.RuneCountInString(message) > 240 { - return false - } - lower := strings.ToLower(message) - for _, marker := range []string{ - "http://", "https://", "ftp://", "file://", "authorization", "cookie", "secret", "token", "api_key", "apikey", "x-api-key", - "/var/", "/tmp/", "\\", "stack trace", "traceback", " at ", "sql:", "sqlite", "postgres", - } { - if strings.Contains(lower, marker) { - return false - } - } - return true -} - -// cloudAgentSafeMediaTaskError preserves a short, user-facing task diagnostic -// while refusing provider details that commonly contain URLs, credentials, or -// internal request metadata. Task.Error is not a safe presentation field. -func cloudAgentSafeMediaTaskError(task *model.Task) string { - if task == nil || strings.TrimSpace(task.Error) == "" { - return "媒体任务未成功" - } - // 与画布节点展示同一套分类:网络/审核/存储/HTTP 状态归类,可读的供应商原因原样保留。 - return userFacingTaskError(task.Error) -} - -func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool { - payload := map[string]any{"toolName": call.Function.Name, "callId": call.ID, "arguments": call.Function.Arguments} - if call.Function.Name == "skill_read_file" { - var args struct { - SkillID string `json:"skillId"` - Path string `json:"path"` - } - if json.Unmarshal([]byte(call.Function.Arguments), &args) == nil { - payload["skillId"], payload["path"] = args.SkillID, args.Path - for _, skill := range state.Skills { - if skill.ID == args.SkillID { - payload["skillName"] = skill.Name - break - } - } - } - } - kind := "tool_completed" - if err != nil { - detail, ok := result.(map[string]any) - if !ok || detail == nil { - detail = map[string]any{} - } - // 模型漏了必填字段(或参数不是对象)时,即便业务校验抛的是普通 AppError,也按 - // "参数契约错误"处理:把本轮实际暴露的 schema 回给模型,它才改得对。 - // 只在业务侧确实按参数问题拒绝(400/422)时套用:上游 5xx 或网络错误即便同时漏字段, - // 也该算上游故障,别把锅扣到参数上。已分类的参数错误不再套壳,避免覆盖更精确的反馈。 - var appErr *AppError - var existingArgumentErr *cloudAgentArgumentError - if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) > 0 && - !errors.As(err, &existingArgumentErr) && - errors.As(err, &appErr) && appErr != nil && (appErr.Status == 400 || appErr.Status == 422) { - err = &cloudAgentFieldArgumentError{ - error: &cloudAgentArgumentError{err}, - Field: strings.Join(missing, ","), Issue: "required", - } - } - message := cloudAgentSafeToolError(err) - detail["error"] = message - var admissionErr *cloudAgentMediaAdmissionError - if errors.As(err, &admissionErr) { - detail["reason"], detail["nodeId"] = admissionErr.Reason, admissionErr.NodeID - } - var argumentErr *cloudAgentArgumentError - if errors.As(err, &argumentErr) { - detail["reason"] = "invalid_tool_arguments" - var fieldErr *cloudAgentFieldArgumentError - if errors.As(err, &fieldErr) { - detail["field"], detail["issue"] = fieldErr.Field, fieldErr.Issue - } - // Use this run's advertised contract, including its permission scope. - for _, tool := range state.Canonical.Tools { - function, _ := tool["function"].(map[string]any) - if function["name"] == call.Function.Name { - detail["parameters"] = function["parameters"] - break - } - } - detail["guidance"] = "本次调用未执行,请按 parameters 修正参数后重试,不要重复提交相同的错误参数" - if call.Function.Name == "canvas_get_state" { - detail["exampleArguments"] = map[string]any{} - } - if call.Function.Name == "canvas_apply_ops" { - detail["exampleArguments"] = map[string]any{"snapshotHash": "", "ops": []any{map[string]any{"type": "add_node", "id": "", "nodeType": "text", "content": ""}}} - } - } - // 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。 - // allowed 与执行器用的是同一份判定(cloudAgentToolAllowed 是纯函数,结果一致)。 - if class, retryable, requiredAction := cloudAgentToolErrorClass(state.Request, call, err, cloudAgentToolAllowed(state.Request, call.Function.Name)); class != "" { - detail["errorClass"], detail["errorClassLabel"] = class, cloudAgentToolErrorLabel(class) - detail["retryable"] = retryable - if requiredAction != "" { - detail["requiredAction"] = requiredAction - } - payload["errorClass"] = class - } - result = detail - kind = "tool_failed" - payload["text"] = message - } else { - payload["text"] = "工具执行成功" - } - if inspection, ok := result.(cloudAgentImageInspection); ok && err == nil { - receipt, _ := json.Marshal(inspection.Receipt) - payload["result"] = inspection.Receipt - state.event(runID, kind, payload) - // tool 角色只接受字符串内容(四种上游图式都是纯文本),因此工具回执照常入历史, - // 图片另起一条 user 消息携带,并显式标注为数据而非指令。 - state.Canonical.Messages = append(state.Canonical.Messages, - map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(receipt)}) - // 重复查看时只回执文字(ImageURL 为空),不再附图。 - if strings.TrimSpace(inspection.ImageURL) != "" { - cloudAgentStageImageInspection(state, inspection) - } - // 一批里可能有多个调用(模型一次发起 parallel tool calls),上游要求 - // assistant(tool_calls) 之后紧跟每一个 tool_call_id 的 tool 消息,所以图片 - // 不能插在 tool 结果之间。只有本批最后一个调用执行完,才把整批缓冲合并成 - // 一条 user 消息追加在全部 tool 结果之后。 - if state.CallIndex+1 >= len(state.Calls) { - cloudAgentFlushPendingImages(state) - } - state.CallIndex++ - state.Approval = nil - // 看图是只读成功路径,不占自动纠错名额(那名额只给写/生成类工具的预执行参数错误)。 - return false - } - exhausted := cloudAgentTrackToolRepair(runID, state, call, result, err, payload) - raw, _ := json.Marshal(result) - payload["result"] = result - if call.Function.Name == "skill_read_file" && err == nil { - // SSE/UI needs the read receipt, not another durable copy of skill text. - if fields, ok := result.(map[string]any); ok { - receipt := make(map[string]any, len(fields)) - for key, value := range fields { - if key != "content" { - receipt[key] = value - } - } - payload["result"] = receipt - } - } - state.event(runID, kind, payload) - state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(raw)}) - state.CallIndex++ - state.Approval = nil - return exhausted -} - -// cloudAgentReadResultInContext reports whether the full cached result is still -// present in the canonical transcript. Context compaction deliberately removes old -// tool bodies, so a replay must restore the body once instead of returning a receipt -// that refers to history the model can no longer see. -func cloudAgentReadResultInContext(state *cloudAgentRuntime, result json.RawMessage) bool { - if state == nil || len(result) == 0 { - return false - } - // Unit-level callers may exercise the cache helper without constructing a - // transcript. The real runtime always has the first tool message here. - if len(state.Canonical.Messages) == 0 { - return true - } - want := string(result) - for _, message := range state.Canonical.Messages { - if stringValue(message["role"]) == "tool" && stringValue(message["content"]) == want { - return true - } - } - return false -} - -// cloudAgentBatchableReadTool identifies read calls that are independent of -// each other and safe to execute from one model tool-call response. Keeping -// this list limited to the cacheable read contract is important: mutations, -// approvals, user questions, media and vision calls remain ordered state -// transitions and must each retain their existing semantics. -func cloudAgentBatchableReadTool(name string) bool { - return cloudAgentReadToolReadOnly(name) -} - -func cloudAgentInvalidateReadCache(state *cloudAgentRuntime) { - if state == nil { - return - } - // Canvas writes invalidate only canvas projections. Skill documents, - // profile preferences, and the model catalog do not change when a canvas is - // edited, so retaining them avoids re-reading large unrelated tool results. - for key := range state.ToolReadResults { - if strings.HasPrefix(key, "canvas_get_state:") || strings.HasPrefix(key, "canvas_read_storyboard:") { - delete(state.ToolReadResults, key) - delete(state.ToolReadReplays, key) - } - } -} - -// advanceCloudAgentReadBatch executes consecutive independent reads from the -// same model response before returning to the scheduler. The previous path -// checkpointed after every read, which turned one parallel tool-call response -// into N scheduler/database transitions. Results still get one tool message -// per call, in the original order, so every provider contract remains valid. -func (s *Service) advanceCloudAgentReadBatch(run *model.CloudAgentExecution, state *cloudAgentRuntime) (bool, error) { - if run == nil || state == nil || (run.Status != "running" && run.Status != "queued") { - return true, nil - } - if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { - return false, nil - } - type outcome struct { - call cloudAgentCall - result any - err error - } - outcomes := make([]outcome, 0, len(state.Calls)-state.CallIndex) - // Reads are collected before one checkpoint write. During collection, their - // successful receipts are not in Canonical.Messages yet, so the regular cache - // layer cannot see that an earlier call in this same batch already returned - // the full body. Track those keys here to avoid appending the same large result - // repeatedly when a model emits duplicate parallel reads. - batchReadResults := make(map[string]bool, len(state.Calls)-state.CallIndex) - for index := state.CallIndex; index < len(state.Calls); index++ { - call := state.Calls[index] - if !cloudAgentBatchableReadTool(call.Function.Name) || !cloudAgentToolAllowed(state.Request, call.Function.Name) { - break - } - call = s.cloudAgentRefreshStepSnapshotHash(run, state, call) - state.Calls[index] = call - if call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" { - state.RuntimeRunID = run.ID - } - result, err := cloudAgentReadToolCached(s.repo, run.UserID, state, call, s) - if err == nil { - cacheKey := cloudAgentReadCacheKeyForState(s.repo, run.UserID, state, call) - if batchReadResults[cacheKey] { - // The first receipt will be appended before this one in the same - // checkpoint transaction, so this acknowledgement is truthful even - // though the canonical transcript has not been updated yet. - result = map[string]any{ - "cacheReplay": true, - "replayCount": state.ToolReadReplays[cacheKey], - "message": "该只读结果已在本批工具调用的前序结果中,请直接使用已有结果,不要再次读取", - } - } else { - batchReadResults[cacheKey] = true - } - } - outcomes = append(outcomes, outcome{call: call, result: result, err: err}) - var loopErr *cloudAgentReadLoopError - if errors.As(err, &loopErr) { - break - } - } - if len(outcomes) == 0 { - return false, nil - } - - s.storageMu.Lock() - defer s.storageMu.Unlock() - err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - for _, item := range outcomes { - var readLoopErr *cloudAgentReadLoopError - if errors.As(item.err, &readLoopErr) { - // A provider tool-call turn is atomic from the transcript's point of - // view: every declared tool_call_id needs a tool message, even when a - // read guard terminates the run. Record the triggering error and then - // explicit skipped receipts before appending run_failed, otherwise a - // later resume/replay produces an invalid provider transcript. - cloudAgentToolResult(current.ID, state, item.call, item.result, item.err) - for state.CallIndex < len(state.Calls) { - pending := state.Calls[state.CallIndex] - cloudAgentToolResult(current.ID, state, pending, - map[string]any{"skipped": true, "reason": readLoopErr.reasonCode()}, - nil) - } - current.Status = "failed" - current.FailureMessage = truncateRunes(readLoopErr.Error(), 1000) - cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(current.FailureMessage, 120), state) - reason := readLoopErr.reasonCode() - state.event(run.ID, "run_failed", map[string]any{ - "text": current.FailureMessage, "reason": reason, - "toolName": item.call.Function.Name, "readCount": readLoopErr.Count, - }) - break - } - cloudAgentRecordToolResult(current, state, item.call, item.result, item.err) - } - return cloudAgentSave(current, state) - }) - return true, err -} - -func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *cloudAgentRuntime) error { - if run == nil || state == nil || (run.Status != "running" && run.Status != "queued") { - return nil - } - if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { - return s.failCloudAgent(run, state, "Agent 工具调用状态无效,本轮已停止") - } - call := state.Calls[state.CallIndex] - call = s.cloudAgentRefreshStepSnapshotHash(run, state, call) - state.Calls[state.CallIndex] = call - if state.Approval != nil && state.Approval.Decision == "" { - return nil - } - if state.Approval != nil && state.Approval.Decision != "" && state.Approval.CallHash != "" && state.Approval.CallHash != cloudAgentApprovalCallHash(call) { - return s.terminateCloudAgent(run, "审批内容与待执行操作不一致,本轮已停止") - } - allowed := cloudAgentToolAllowed(state.Request, call.Function.Name) - mediaTool := call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" - // 媒体工具在所有模式下都进入审批;其他写入只在 request_approval 下审批。 - if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || mediaTool) && state.Approval == nil { - var plan *cloudAgentMediaPlan - var modelName string - var mediaRequest CreateTaskRequest - var preparedTask *model.Task - var mediaPreparation *creationTaskPreparation - policy, err := s.RuntimePolicy() - if err != nil { - return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止") - } - if call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" { - mediaCall := cloudAgentMediaCall(call) - req, prepared, err := s.prepareCloudAgentMedia(run, state, mediaCall) - if err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - mediaRequest, plan = req, prepared - // A durable auto checkpoint already contains the exact admitted input and - // quote. Reusing it avoids a second dry admission after a worker restart. - if plan.Prepared == nil { - // Dry admission validates the selected model and prompt limits without a task or charge. - mediaPreparation = &creationTaskPreparation{} - req.creationPrepare = mediaPreparation - preparedTask, err = s.CreateTask(run.UserID, req) - if err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - // Resolve model-owned defaults once during the dry admission. Both the - // eventual task and the canvas draft must use this exact resolved input; - // otherwise auto mode reports a false tool failure for omitted size or - // duration and makes the model spend another turn repairing its own call. - if err := applyCloudAgentResolvedMediaDefaults(&req, prepared, preparedTask); err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - mediaRequest, plan = req, prepared - } - modelName, err = s.cloudAgentMediaModelName(plan.Args) - if err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - } - if plan != nil && state.Request.PermissionMode == "auto" && !mediaTool { - if plan.Prepared != nil { - // The draft and quote were already checkpointed. Reuse them after a - // worker restart instead of dry-admitting and mutating the canvas again. - latest, err := s.repo.CloudAgent(run.UserID, run.ID) - if err != nil { - return err - } - fresh, err := cloudAgentDecode(latest) - if err != nil { - return err - } - return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan) - } - // Auto media is a direct, server-admitted write. Prepare the draft and - // its immutable quote in one checkpoint transaction, then release the - // lock before enqueueCloudAgentTask performs the billed submission. - s.storageMu.Lock() - err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { - return err - } - canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) - if err != nil { - return err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return err - } - plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc) - raw, err := json.Marshal(plan.Args) - if err != nil { - return err - } - call.Function.Arguments = string(raw) - state.Calls[state.CallIndex] = call - preparedMedia, err := prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order) - if err != nil { - return err - } - plan.Prepared = preparedMedia - state.AutoPreparedMedia = preparedMedia - state.AutoPreparedCallHash = cloudAgentApprovalCallHash(call) - return cloudAgentSave(current, state) - }) - s.storageMu.Unlock() - if err != nil { - latest, readErr := s.repo.CloudAgent(run.UserID, run.ID) - if readErr != nil || latest.Revision != run.Revision { - return err - } - fresh, decodeErr := cloudAgentDecode(latest) - if decodeErr != nil { - return decodeErr - } - return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err) - } - latest, err := s.repo.CloudAgent(run.UserID, run.ID) - if err != nil { - return err - } - fresh, err := cloudAgentDecode(latest) - if err != nil { - return err - } - return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan) - } - s.storageMu.Lock() - defer s.storageMu.Unlock() - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - var preview cloudAgentApprovalPreview - var preparedMedia *cloudAgentPreparedMedia - if plan != nil { - if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { - return err - } - canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) - if err != nil { - return err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return err - } - plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc) - raw, err := json.Marshal(plan.Args) - if err != nil { - return err - } - call.Function.Arguments = string(raw) - state.Calls[state.CallIndex] = call - preview = cloudAgentMediaApprovalPreview(plan, modelName) - preparedMedia, err = prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order) - if err != nil { - return err - } - } else { - var mutationErr error - switch call.Function.Name { - case "canvas_create_storyboard": - storyboardPlan, err := prepareCloudAgentStoryboardCreate(repo, run.UserID, state.Request.CanvasID, call) - mutationErr = err - if err == nil { - preview = storyboardPlan.Preview - } - case "canvas_edit_storyboard": - storyboardPlan, err := prepareCloudAgentStoryboardEdit(repo, run.UserID, state.Request.CanvasID, call) - mutationErr = err - if err == nil { - preview = storyboardPlan.Preview - } - case "canvas_edit_batch_table": - batchPlan, err := prepareCloudAgentBatchTableEdit(repo, run.UserID, state.Request.CanvasID, call) - mutationErr = err - if err == nil { - preview = batchPlan.Preview - } - case "canvas_arrange_nodes": - arrangePlan, err := prepareCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call) - mutationErr = err - if err == nil { - preview = arrangePlan.Preview - } - default: - canvasPlan, err := prepareCloudAgentCanvasMutation(repo, run.UserID, state.Request.CanvasID, call) - mutationErr = err - if err == nil { - preview = canvasPlan.Preview - } - } - if mutationErr != nil { - var argumentErr *cloudAgentArgumentError - if errors.As(mutationErr, &argumentErr) { - cloudAgentRecordToolResult(current, state, call, nil, mutationErr) - return cloudAgentSave(current, state) - } - var appErr *AppError - if errors.As(mutationErr, &appErr) && appErr != nil { - return failCloudAgentAdmission(current, state, run.ID, mutationErr) - } - return mutationErr - } - } - state.Approval = &cloudAgentApproval{ID: fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex), Call: call, CallHash: cloudAgentApprovalCallHash(call), Preview: preview, ModelName: modelName, Prepared: preparedMedia} - if preparedMedia != nil { - if err := pinCloudAgentPreparedMedia(repo, run.UserID, run.ID, state.Approval.ID, preparedMedia); err != nil { - return err - } - } - current.Status = "waiting_approval" - state.event(run.ID, "approval_requested", map[string]any{"approvalId": state.Approval.ID, "toolName": call.Function.Name, "modelName": modelName, "arguments": json.RawMessage(call.Function.Arguments), "preview": preview, "prepared": preparedMedia.publicView(), "text": preview.Description}) - return cloudAgentSave(current, state) - }) - } - if allowed && call.Function.Name == "plan_update" && cloudAgentPlanRequiresFirstApproval(state, call) { - if preview, ok := cloudAgentPlanApprovalPreview(call); ok { - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - approvalID := fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex) - state.Approval = &cloudAgentApproval{ID: approvalID, Call: call, CallHash: cloudAgentApprovalCallHash(call), Preview: preview} - current.Status = "waiting_approval" - state.event(run.ID, "approval_requested", map[string]any{"approvalId": approvalID, "toolName": call.Function.Name, "arguments": json.RawMessage(call.Function.Arguments), "preview": preview, "text": preview.Description}) - return cloudAgentSave(current, state) - }) - } - } - if allowed && (call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split") && state.Approval != nil && state.Approval.Decision == "approve" { - return s.advanceCloudAgentMedia(run, state, cloudAgentMediaCall(call)) - } - policy, err := s.RuntimePolicy() - if err != nil { - return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止") - } - // 看图的资源与能力校验在写事务外完成;真实图片只在模型任务执行时读取。 - var inspectionResult any - var inspectionErr error - if allowed && call.Function.Name == "canvas_inspect_image" && state.Request.VisionEnabled { - inspectionResult, inspectionErr = s.prepareCloudAgentImageInspection(run.UserID, state.Request.CanvasID, state, call) - if errors.Is(inspectionErr, errCloudAgentImageInspectionBudget) { - return s.failCloudAgent(run, state, cloudAgentImageInspectionBudgetMessage) - } - } - // Skill reads use the domain repository and filesystem, not the checkpoint - // transaction's connection. Read first to avoid nesting DB reads on SQLite. - var skillResult any - var skillErr error - if allowed && (call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" || call.Function.Name == "image_annotation_render") { - state.RuntimeRunID = run.ID - if call.Function.Name == "image_annotation_render" { - skillResult, skillErr = cloudAgentReadTool(s.repo, run.UserID, state, call, s) - } else { - // 技能文件和模型目录都是稳定的只读结果。统一走检查点缓存, - // 使重复调用不会再次访问文件系统/数据库,也不会把大结果重复 - // 写入后续模型上下文。 - skillResult, skillErr = cloudAgentReadToolCached(s.repo, run.UserID, state, call, s) - } - } - s.storageMu.Lock() - defer s.storageMu.Unlock() - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - var result any - var toolErr error - switch { - case !allowed: - // 幻觉出来的工具名与"真被权限挡住"要分开反馈:前者模型根本不该发这个调用, - // 后者是授权边界问题。两者仍然一律拒绝,只是给模型的下一步更明确。 - if !cloudAgentPlatformToolNames()[call.Function.Name] { - toolErr = BadAuthRequest("模型调用了不存在的工具「" + truncateRunes(call.Function.Name, 60) + "」,本轮已拒绝;请只使用本轮工具表里列出的工具") - } else { - toolErr = BadAuthRequest("工具未获本轮权限授权") - } - case call.Function.Name == "canvas_apply_ops": - result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) - case call.Function.Name == "canvas_arrange_nodes": - result, toolErr = applyCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) - case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard": - result, toolErr = applyCloudAgentStoryboardMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) - case call.Function.Name == "canvas_edit_batch_table": - result, toolErr = applyCloudAgentBatchTableMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) - case call.Function.Name == "canvas_inspect_image": - result, toolErr = inspectionResult, inspectionErr - if toolErr == nil && inspectionResult != nil { - if inspection, ok := inspectionResult.(cloudAgentImageInspection); ok { - state.markCanvasImageInspection(stringValue(inspection.Receipt["nodeId"]), strings.TrimSpace(inspection.ImageURL) != "") - if inspection.CacheKey != "" { - if state.ImageInspectionReads == nil { - state.ImageInspectionReads = map[string]int{} - } - state.ImageInspectionReads[inspection.CacheKey]++ - } - } - } - case call.Function.Name == "skill_read_file", call.Function.Name == "model_list", call.Function.Name == "image_annotation_render": - result, toolErr = skillResult, skillErr - default: - result, toolErr = cloudAgentReadToolCached(repo, run.UserID, state, call) - } - if toolErr == nil && cloudAgentWrite(call.Function.Name) { - // A successful canvas mutation changes the read model. Do not replay a - // pre-mutation canvas snapshot later in the same Agent run. - cloudAgentInvalidateReadCache(state) - } - var readLoopErr *cloudAgentReadLoopError - if errors.As(toolErr, &readLoopErr) { - cloudAgentRecordToolResult(current, state, call, result, toolErr) - current.Status = "failed" - current.FailureMessage = truncateRunes(readLoopErr.Error(), 1000) - cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(current.FailureMessage, 120), state) - reason := readLoopErr.reasonCode() - state.event(run.ID, "run_failed", map[string]any{ - "text": current.FailureMessage, "reason": reason, - "toolName": call.Function.Name, "readCount": readLoopErr.Count, - }) - return cloudAgentSave(current, state) - } - if call.Function.Name == "plan_update" && toolErr == nil { - state.event(run.ID, "plan_updated", map[string]any{"items": state.Plan, "pendingTitles": cloudAgentPendingPlanItems(state.Plan)}) - } - if call.Function.Name == "ask_user" && toolErr == nil { - payload, _ := result.(map[string]any) - if payload["phase"] == "question" { - state.event(run.ID, "user_question", payload) - cloudAgentRecordToolResult(current, state, call, result, nil) - skipRemainingCloudAgentCalls(run.ID, state) - current.Status = "completed" - return cloudAgentSave(current, state) - } - // The server-side round limit turns further questions into a normal - // tool result so the model must continue with safe defaults. - cloudAgentRecordToolResult(current, state, call, result, nil) - return cloudAgentSave(current, state) - } - cloudAgentRecordToolResult(current, state, call, result, toolErr) - return cloudAgentSave(current, state) - }) -} - -// image_layer_split deliberately reuses the canonical media admission path. -// Keeping the alias at this boundary preserves one approval, billing and -// write-back implementation while exposing a task-specific Agent affordance. -func cloudAgentMediaCall(call cloudAgentCall) cloudAgentCall { - if call.Function.Name != "image_layer_split" { - return call - } - var args map[string]any - if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err == nil { - args["mode"] = "image" - if raw, err := json.Marshal(args); err == nil { - call.Function.Arguments = string(raw) - } - } - return call -} - -func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *cloudAgentRuntime, req CreateTaskRequest, media *cloudAgentMediaPlan) error { - orders, err := s.repo.BillingOrdersByTaskIDs(run.UserID, state.TaskIDs) - if err != nil { - return err - } - remaining := int64(math.Floor(state.Request.Budget.MaxCredits * float64(CreditScale))) - for _, order := range orders { - remaining -= order.AmountMicrocredits - } - if remaining < 0 { - return s.failCloudAgent(run, state, "Agent 累计预算已耗尽") - } - var prepared *cloudAgentPreparedMedia - approvalID, generationID := "", "" - if media != nil { - prepared = media.Prepared - if prepared == nil && state.Approval != nil { - prepared = state.Approval.Prepared - if state.Approval != nil { - approvalID = state.Approval.ID - } - } - if prepared == nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("缺少已批准的生成准备态,请重新申请审批;未提交任务")) - } - generationID = prepared.GenerationID - refs, refErr := cloudAgentPreparedReferences(s.repo, run.UserID, prepared) - if refErr != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, refErr) - } - if req.Input == nil { - req.Input = map[string]any{} - } - for _, field := range []string{"referenceImages", "referenceVideos", "referenceAudios"} { - delete(req.Input, field) - } - for field, value := range refs { - req.Input[field] = value - } - } - prepare := &creationTaskPreparation{} - req.admission = &taskAdmission{ID: cloudAgentID(run.UserID, fmt.Sprintf("%s:task:%d", run.ID, len(state.TaskIDs))), MaxCharge: remaining, AgentRunID: run.ID, GenerationID: generationID, ApprovalID: approvalID} - req.creationPrepare = prepare - task, err := s.CreateTask(run.UserID, req) - if err != nil { - if media != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - return s.failCloudAgent(run, state, cloudAgentSafeToolError(err)) - } - var input map[string]any - if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil { - if media != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - return err - } - if media != nil { - requested, _ := req.Input["config"].(map[string]any) - resolved, _ := input["config"].(map[string]any) - if err := validateCloudAgentResolvedMediaOptions(requested, resolved); err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - } - if err = s.protectTaskSecrets(input); err != nil { - if media != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - return err - } - raw, err := json.Marshal(input) - if err != nil { - if media != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - return err - } - task.InputJSON = string(raw) - // 压力读数只对"模型调用"这一步有意义:媒体任务发的是另一份请求(另一套信封), - // 把读数挂到那份信封上会误导消费方。 - var contextPressure *cloudAgentContextPressure - var requestCanonical canonicalAgentRequest - if media == nil { - if req.Operation != cloudAgentContextCompactionOperation { - canonical, ok := canonicalAgentRequestFromInput(input) - if !ok { - return fmt.Errorf("Agent 模型任务缺少可计量的请求信封") - } - requestCanonical = canonical - value := s.cloudAgentContextPressure(canonical, state.Request.Prompt, state.Request) - contextPressure = &value - } - } - if prepare.Order != nil { - task.BillingOrderID = prepare.Order.ID - } - policy, err := s.RuntimePolicy() - if err != nil { - if media != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - return err - } - s.storageMu.Lock() - defer s.storageMu.Unlock() - err = s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - if media != nil { - canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) - if err != nil { - return err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return err - } - if err := validateCloudAgentPreparedAdmission(repo, run.UserID, prepared, task, prepare.Order, doc, media.Args); err != nil { - return err - } - if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, media, task, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { - return err - } - } - if err := createTaskWithStorageQuotaRepository(repo, task, prepare.Order, policy); err != nil { - return err - } - if media != nil && prepared != nil { - if approvalID != "" { - if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil { - return err - } - } else { - // Auto execution has no approval owner to transfer from. Attach the - // resource lease directly to the billed task before it can run. - ids := make([]string, 0, len(prepared.ResourceSignatures)) - for id := range prepared.ResourceSignatures { - ids = append(ids, id) - } - if err := repo.UpsertCloudAgentResourceLeases(run.UserID, run.ID, "task:"+task.ID, ids, prepared.Quote.ExpiresAt); err != nil { - return err - } - } - } - if media != nil { - // The prepared auto admission is single-use. Once the billed task and - // draft node are committed, a later retry must observe the task state, - // not reuse the old quote or create a second task. - state.AutoPreparedMedia = nil - state.AutoPreparedCallHash = "" - } - state.TaskIDs = append(state.TaskIDs, task.ID) - if media != nil { - state.MediaTaskID = task.ID - state.Generations++ - state.VideoSeconds += media.Args.Duration - toolName := "generate_media" - if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) && state.Calls[state.CallIndex].Function.Name != "" { - toolName = state.Calls[state.CallIndex].Function.Name - } - state.event(run.ID, "generation_task_created", map[string]any{"toolName": toolName, "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"}) - } else { - state.ActiveTaskID = task.ID - // 压缩调用不是本轮的一步:压完还要用压缩后的上下文继续步进,步数不该被它占掉。 - if state.ContextCompaction != nil && req.Operation == cloudAgentContextCompactionOperation { - state.ContextCompaction.Status = "running" - } else { - if contextPressure != nil { - config, _ := input["config"].(map[string]any) - model, channelID := stringValue(config["model"]), stringValue(config["channelId"]) - signature := cloudAgentRequestSignature(state, requestCanonical, channelID, model) - cloudAgentExpireTokenAnchorForRequest(run.ID, state, contextPressure.ContextWindowTokens, signature, model, channelID) - cloudAgentRecordMemorySegment(&state.Policy, requestCanonical.SystemPrompt) - payload := cloudAgentContextPressurePayload(*contextPressure, state, requestCanonical) - payload["requestId"] = task.ID - cloudAgentNoteContextWindowResolved(run.ID, state, *contextPressure) - state.event(run.ID, "context_pressure", payload) - state.LastStepTaskID = task.ID - state.LastStepOperation = req.Operation - state.LastStepEstimate = contextPressure.EstimatedInputTokens - state.LastStepSourceBytes = contextPressure.SourceBytes - state.LastStepSignature = signature - state.LastStepModel = model - state.LastStepChannelID = channelID - state.LastStepWindowTokens = contextPressure.ContextWindowTokens - } - state.Step++ - } - } - return cloudAgentSave(current, state) - }) - if err != nil && media != nil { - // Rollback may have happened after checkpoint edits. Reload before recording - // a tool failure; never turn a stale worker revision into a second result. - latest, readErr := s.repo.CloudAgent(run.UserID, run.ID) - if readErr != nil || latest.Revision != run.Revision { - return err - } - fresh, decodeErr := cloudAgentDecode(latest) - if decodeErr != nil { - return decodeErr - } - return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err) - } - return err -} - -func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cloudAgentRuntime, phase string, submitted, terminal bool, err error) error { - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { - current.Status = "failed" - state.event(run.ID, "run_failed", map[string]any{"text": "Agent 媒体调用状态无效,本轮已停止"}) - return cloudAgentSave(current, state) - } - if phase == "admission" && !submitted { - err = cloudAgentWrapMediaAdmissionError(err) - } - toolName := state.Calls[state.CallIndex].Function.Name - cloudAgentRecordToolResult(current, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err) - // Any admission failure advances the call into the repair path. Do not let - // a prepared quote from the failed attempt leak into the corrected call. - state.AutoPreparedMedia = nil - state.AutoPreparedCallHash = "" - if submitted { - state.MediaTaskID = "" - } - // Only explicitly typed argument errors and a stale canvas snapshot may - // continue into another model turn. Every other pre-submission media - // failure is a real admission boundary failure; continuing would invite - // the model to submit unverified variants of a billed write. - continueAfterAdmissionError := false - if phase == "admission" && !submitted { - var argumentErr *cloudAgentArgumentError - continueAfterAdmissionError = errors.As(err, &argumentErr) - var admissionErr *cloudAgentMediaAdmissionError - if errors.As(err, &admissionErr) && admissionErr.Reason == "snapshot_conflict" { - continueAfterAdmissionError = true - } - } - if terminal || (phase == "admission" && !submitted && !continueAfterAdmissionError) { - current.Status = "failed" - message := "媒体生成准入失败,本轮已停止;请检查模型、能力和预算后由用户明确重试" - reason := "tool_admission_failed" - if terminal { - message = "媒体任务已提交,但结果处理失败;任务不会自动重试" - reason = "media_task_failed" - } - current.FailureMessage = truncateRunes(message, 1000) - cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(message, 120), state) - state.event(run.ID, "run_failed", map[string]any{"text": message, "reason": reason, "toolName": toolName}) - } - return cloudAgentSave(current, state) - }) -} - -func (s *Service) advanceCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) error { - if state.MediaTaskID != "" { - task, err := s.repo.TaskForUser(run.UserID, state.MediaTaskID) - if errors.Is(err, gorm.ErrRecordNotFound) { - return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("媒体任务不存在或已失去归属,结果未回写画布")) - } - if err != nil { - return err - } - if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning { - return nil - } - policy, err := s.RuntimePolicy() - if err != nil { - return err - } - var target struct { - NodeID string `json:"nodeId"` - } - if err := json.Unmarshal([]byte(call.Function.Arguments), &target); err != nil || validateCloudAgentID(target.NodeID, "生成节点ID", 80) != nil { - return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("已提交媒体任务的目标节点记录无效,未回写画布")) - } - s.storageMu.Lock() - defer s.storageMu.Unlock() - return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - before, readErr := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) - nodeID, writeErr := completeCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, target.NodeID, task, policy) - if writeErr != nil { - var appErr *AppError - if !errors.Is(writeErr, gorm.ErrRecordNotFound) && !(errors.As(writeErr, &appErr) && (appErr.Status == 400 || appErr.Status == 409)) { - return writeErr // Retry persistence, never resubmit the billed generation. - } - } - // A deleted canvas/node must still checkpoint the tool failure. Only - // a matched node can have changed and require an atomic canvas delta. - if nodeID != "" { - if readErr != nil { - return readErr - } - if err := emitCloudAgentCanvasChange(repo, run.ID, state, cloudAgentMutationInput{UserID: run.UserID, CanvasID: state.Request.CanvasID, BeforeJSON: before.PayloadJSON, Operation: "generate_media_complete"}); err != nil { - return err - } - } - result := map[string]any{"phase": "completion", "taskSubmitted": true, "taskId": task.ID, "nodeId": nodeID, "targetNodeId": target.NodeID, "status": task.Status} - var toolErr error - generationMessage := "" - writebackMessage, writebackReason := "", "" - if task.Status != model.TaskStatusSucceeded { - generationMessage = truncateRunes(cloudAgentSafeMediaTaskError(task), 90) - if !cloudAgentSafeUserMessage(generationMessage) { - generationMessage = "媒体任务未成功" - } - result["generationError"] = generationMessage - toolErr = BadAuthRequest("媒体任务未成功:" + generationMessage + ";请在任务中心查看任务详情,不会自动重试收费生成") - result["summary"] = "媒体任务未成功;任务记录保留在任务中心" - } - if writeErr != nil { - writebackMessage = truncateRunes(cloudAgentSafeToolError(writeErr), 60) - writebackReason = "canvas_writeback_failed" - var writeback *cloudAgentMediaWritebackError - if errors.As(writeErr, &writeback) { - writebackReason = writeback.reason - } - result["writebackError"], result["writebackReason"] = writebackMessage, writebackReason - message := "媒体任务已成功,但画布回写未完成:" + writebackMessage - if generationMessage != "" { - message = "媒体任务未成功:" + generationMessage + ";任务状态也未回写画布:" + writebackMessage - } - toolErr = BadAuthRequest(message + "。请在任务中心查看详情,不会自动重试收费生成") - result["summary"] = "画布回写未完成;任务记录保留在任务中心" - } - if task.Status == model.TaskStatusSucceeded && writeErr == nil { - // Completion writes the generated asset into the canvas. Invalidate - // the same-run snapshot cache just like ordinary canvas writes. - cloudAgentInvalidateReadCache(state) - result["summary"] = "生成结果已回写画布节点" - } - if writeErr != nil { - recordTaskWritebackDiagnostic(task, target.NodeID, "failed", writebackReason, writeErr) - } else { - recordTaskWritebackDiagnostic(task, target.NodeID, "succeeded", "", nil) - } - if err := repo.UpdateTaskExecutionDiagnostic(run.UserID, task.ID, task.ExecutionDiagnosticJSON); err != nil { - return err - } - // A billed generation failure is a tool result, not a dead run: the - // model must still be able to tell the user what happened. Only a - // canvas write that cannot land is terminal for the whole turn. - if writeErr != nil { - if current.Status != "cancelled" { - current.Status = "failed" - } - current.FailureMessage = cloudAgentSafeToolError(toolErr) - state.event(run.ID, "run_failed", map[string]any{ - "text": current.FailureMessage, "taskId": task.ID, "nodeId": target.NodeID, - "reason": result["writebackReason"], "generationStatus": task.Status, - "generationError": generationMessage, "writebackError": result["writebackError"], - }) - } - cloudAgentToolResult(run.ID, state, call, result, toolErr) - state.MediaTaskID = "" - return cloudAgentSave(current, state) - }) - } - req, plan, err := s.prepareCloudAgentMedia(run, state, call) - if err != nil { - return s.cloudAgentMediaError(run, state, "admission", false, false, err) - } - return s.enqueueCloudAgentTask(run, state, req, plan) -} - -func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, reason string, mediaSettings ...*CloudAgentMediaSettings) error { - // Resource leases are a protection mechanism only. Expiry cleanup is safe to - // run on the control-plane path and never changes the approval decision. - if err := s.repo.ReleaseExpiredCloudAgentResourceLeases(time.Now().UTC()); err != nil { - return err - } - var settings *CloudAgentMediaSettings - if len(mediaSettings) > 1 { - return BadAuthRequest("只能提交一组生成参数") - } - if len(mediaSettings) == 1 { - settings = mediaSettings[0] - } - if settings != nil && decision != "approve" { - return BadAuthRequest("仅批准生成时可修改生成参数") - } - if decision != "approve" && decision != "reject" { - return BadAuthRequest("无效审批决定") - } - if len(reason) > 2000 { - return BadAuthRequest("审批理由过长") - } - if _, _, err := s.cloudAgentTask(userID, id); err != nil { - return err - } - run, err := s.repo.CloudAgent(userID, id) - if err != nil { - return err - } - state, err := cloudAgentDecodeForExecution(run) - if err != nil { - return err - } - if previous, ok := state.Decisions[approvalID]; ok { - if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { - return nil - } - return cloudAgentDecisionConflict(previous) - } - if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { - return creationConflict("审批不存在或已过期") - } - // 只在记录审批决定的这次写入期间持锁:随后的工具执行(advanceCloudAgentTool) - // 会自己获取 storageMu,持锁到函数返回会造成自锁。旧 Pi 会话退出前仍可能 - // 写入最后一个事件,因此审批决定采用重读后 CAS 重试。 - for attempt := 0; attempt < 8; attempt++ { - if attempt > 0 { - time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) - run, err = s.repo.CloudAgent(userID, id) - if err != nil { - return err - } - state, err = cloudAgentDecodeForExecution(run) - if err != nil { - return err - } - if previous, ok := state.Decisions[approvalID]; ok { - if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { - return nil - } - return cloudAgentDecisionConflict(previous) - } - if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { - return creationConflict("审批不存在或已过期") - } - } - s.storageMu.Lock() - err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { - if settings != nil { - if err := s.updateCloudAgentMediaApproval(repo, run, &state, *settings); err != nil { - return err - } - } - state.Approval.Decision = decision - state.Approval.Reason = reason - state.Decisions[approvalID] = decision - if settings != nil { - if state.DecisionSettings == nil { - state.DecisionSettings = map[string]string{} - } - state.DecisionSettings[approvalID] = creationHash(settings) - } - if decision == "approve" && state.Approval.Prepared != nil { - if state.DecisionPreparedHashes == nil { - state.DecisionPreparedHashes = map[string]string{} - } - state.DecisionPreparedHashes[approvalID] = state.Approval.Prepared.Hash - } - if decision == "reject" { - // Rejection is a user control-plane decision, not a failed tool - // invocation. Make it terminal before the scheduler can advance the - // pending call; no tool result, canvas mutation, generation task or - // follow-up model request may be produced from this decision. - current.Status = "rejected" - current.FailureMessage = "" - state.Approval = nil - state.event(id, "approval_decided", map[string]any{ - "approvalId": approvalID, - "decision": decision, - "reason": reason, - "text": "已拒绝本次生成,草稿节点仍保留在画布中;未提交任务、未产生扣费。你可以继续编辑后重新申请。", - }) - if err := repo.ReleaseCloudAgentResourceLeases(userID, approvalID); err != nil { - return err - } - return cloudAgentSave(current, &state) - } - current.Status = "running" - payload := map[string]any{"approvalId": approvalID, "decision": decision, "arguments": json.RawMessage(state.Approval.Call.Function.Arguments), "preview": state.Approval.Preview, "modelName": state.Approval.ModelName} - if state.Approval.Prepared != nil { - payload["preparedHash"] = state.Approval.Prepared.Hash - payload["generationId"] = state.Approval.Prepared.GenerationID - } - state.event(id, "approval_decided", payload) - return cloudAgentSave(current, &state) - }) - s.storageMu.Unlock() - if !errors.Is(err, repository.ErrCreationConflict) { - break - } - } - if err != nil || decision != "approve" { - return err - } - // Approval only releases the paused tool. Execute it once in the Go business - // executor, then give the durable result back to Pi; Go never asks a model - // what to do next. - var mediaTaskID string - for attempt := 0; attempt < 8; attempt++ { - if attempt > 0 { - time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) - } - latest, err := s.repo.CloudAgent(userID, id) - if err != nil { - return err - } - state, err = cloudAgentDecode(latest) - if err != nil { - return err - } - err = s.advanceCloudAgentTool(latest, &state) - if !errors.Is(err, repository.ErrCreationConflict) { - if err != nil { - return err - } - mediaTaskID = state.MediaTaskID - break - } - if attempt == 7 { - return err - } - } - if mediaTaskID == "" { - return s.resumeCloudAgentAfterApproval(userID, id, &state) - } - // 媒体生成可能要几分钟:审批请求立即返回,等待与回写在后台完成后再恢复运行。 - s.startApprovedCloudAgentMediaWaiter(userID, id, mediaTaskID) - return nil -} - -func (s *Service) finishApprovedCloudAgentMedia(ctx context.Context, userID, id, mediaTaskID string) error { - mediaTask, err := s.waitCloudAgentTask(ctx, mediaTaskID) - if mediaTask == nil { - // 失败的任务也要回写(记录工具失败并释放 MediaTaskID);只有读不到任务才中止。 - if mediaTask, err = s.repo.Task(mediaTaskID); err != nil { - return err - } - } - if err = s.settleCloudAgentMedia(userID, id); err != nil { - return err - } - latest, err := s.repo.CloudAgent(userID, id) - if err != nil { - return err - } - state, err := cloudAgentDecode(latest) - if err != nil { - return err - } - if mediaTask.Status != model.TaskStatusSucceeded { - state.PiResumePrompt = "用户已批准该操作,但媒体任务未成功完成。请根据工具结果告知用户,不要重复提交该操作。" - } - return s.resumeCloudAgentAfterApproval(userID, id, &state) -} - -func (s *Service) resumeCloudAgentAfterApproval(userID, id string, state *cloudAgentRuntime) error { - if state.PiResumePrompt == "" { - state.PiResumePrompt = "用户已批准刚才等待审批的操作。业务执行器已执行一次;请根据最新工具结果继续,不要重复调用该操作。" - } - if err := s.saveCloudAgentPiResumePrompt(userID, id, state.PiResumePrompt); err != nil { - return err - } - s.resumeCloudAgentPi(userID, id) - return nil -} - -// saveCloudAgentPiResumePrompt 只合并恢复提示词,不覆盖其它并发写入的字段。 -func (s *Service) saveCloudAgentPiResumePrompt(userID, id, prompt string) error { - for attempt := 0; attempt < 8; attempt++ { - run, err := s.repo.CloudAgent(userID, id) - if err != nil { - return err - } - err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - fresh, err := cloudAgentDecode(current) - if err != nil { - return err - } - fresh.PiResumePrompt = prompt - return cloudAgentSave(current, &fresh) - }) - if !errors.Is(err, repository.ErrCreationConflict) { - return err - } - } - return repository.ErrCreationConflict -} -func (s *Service) CancelCloudAgent(ctx context.Context, userID, id string) error { - s.stopCloudAgentPi(id) - // Cancellation is a control-plane operation. It must remain available even - // when the user-facing runtime blob is damaged, so authenticate/authorize - // from the task row first instead of calling CloudAgentRun up front. - task, err := s.repo.TaskForUser(userID, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return kernel.NotFound("Agent 运行不存在") - } - return err - } - if task.Operation != cloudAgentOperation { - return kernel.NotFound("Agent 运行不存在") - } - run, err := s.repo.CloudAgent(userID, id) - if errors.Is(err, gorm.ErrRecordNotFound) { - // Legacy root tasks may not have an execution row yet. The normal read - // path validates the signed/deterministic task identity before creating it. - if _, err = s.CloudAgentRun(userID, id); err != nil { - return err - } - run, err = s.repo.CloudAgent(userID, id) - } - if err != nil { - return err - } - if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) { - return nil - } - // 取消是用户操作,不能因为运行时正在并发写事件就失败:冲突时重读最新 revision 重试。 - for attempt := 0; run.Status != "failed" && attempt < 8; attempt++ { - if attempt > 0 { - time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) - if run, err = s.repo.CloudAgent(userID, id); err != nil { - return err - } - if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) { - return nil - } - } - // Persist intent independently of the transcript. Retrying also repairs - // legacy cancelled rows that crashed before cancelling their children. - state, decodeErr := cloudAgentDecode(run) - err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { - firstCancellation := current.Status != "cancelled" - current.Status = "cancelled" - current.CleanupPending = true - if decodeErr == nil { - current.CanvasID, current.ActiveTaskID, current.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID - if firstCancellation { - state.event(id, "run_cancelled", map[string]any{"source": "user_request", "activeTaskId": state.ActiveTaskID, "mediaTaskId": state.MediaTaskID, "text": "用户取消接口已接收请求,正在取消关联任务"}) - if saveErr := cloudAgentSave(current, &state); saveErr != nil { - // Cancellation must still work if the transcript is oversized. - log.Printf("[cloud-agent] cancellation event unavailable for run %s: checkpoint rejected", id) - } - } - } else { - log.Printf("[cloud-agent] cancellation event unavailable for run %s: invalid transcript", id) - } - return nil - }) - if errors.Is(err, repository.ErrCreationConflict) { - continue - } - if err != nil { - return err - } - break - } - if errors.Is(err, repository.ErrCreationConflict) { - return err - } - latest, err := s.repo.CloudAgent(userID, id) - if err != nil { - return err - } - return s.finishCloudAgentCleanup(ctx, latest) -} diff --git a/backend/internal/app/cloud_agent_runtime_errors.go b/backend/internal/app/cloud_agent_runtime_errors.go new file mode 100644 index 000000000..8c5a53fef --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_errors.go @@ -0,0 +1,82 @@ +// 画布 Agent 面向用户的错误文案净化。 +// +// 模型、工具与上游媒体服务的原始错误可能包含密钥、内部地址或冗长堆栈; +// 这里统一转换成可读、可展示的中文说明,原始错误只进诊断日志。 + +package app + +import ( + "errors" + "strings" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" +) + +// Only expose known failure categories; raw provider errors can contain URLs and credentials. +func cloudAgentModelFailure(task *model.Task) (string, string) { + detail, reason := "模型任务未成功", "model_task_failed" + if diagnostic := taskExecutionDiagnostic(task); diagnostic != nil && diagnostic.Code == string(ReasonUpstreamDNSFailed) { + return "模型服务域名解析失败,请检查渠道域名和后端 DNS 配置;本轮已停止。请在任务中心检查模型任务 " + task.ID, string(ReasonUpstreamDNSFailed) + } + raw := strings.ToLower(task.Error) + switch { + case strings.Contains(task.Error, "没有返回内容"): + // 思考模型的典型失败:整个输出预算被推理吃掉,正文与工具调用皆空。 + detail, reason = "上游连续返回空内容(通常是思考占满输出预算);已自动关思考并放大预算重试仍失败,建议换用非思考模型或调小上下文", "model_empty_output" + case strings.Contains(task.Error, cloudAgentStepTimeoutError): + detail, reason = "单步模型调用超过执行时限仍未返回(长思考或上下文过大时常见);已自动关思考重试仍超时,可在管理端调大 Agent 单步超时", "model_step_timeout" + case strings.Contains(raw, "connection reset by peer"): + detail, reason = "模型连接被对端或中间网络设备重置", "model_connection_reset" + case strings.Contains(raw, "timeout"), strings.Contains(raw, "deadline exceeded"): + detail, reason = "模型请求超时", "model_request_timeout" + case strings.Contains(raw, "connection refused"): + detail, reason = "无法连接模型服务(连接被拒绝)", "model_connection_refused" + } + return detail + ";本轮已停止。请在任务中心检查模型任务 " + task.ID, reason +} + +func cloudAgentSafeToolError(err error) string { + if err == nil { + return "" + } + var readLoopErr *cloudAgentReadLoopError + if errors.As(err, &readLoopErr) { + return readLoopErr.Error() + } + var appErr *AppError + if errors.As(err, &appErr) && appErr != nil { + message := strings.TrimSpace(appErr.Message) + if cloudAgentSafeUserMessage(message) { + return message + } + } + return "工具执行失败,请检查输入或稍后重试" +} + +func cloudAgentSafeUserMessage(message string) bool { + if message == "" || !utf8.ValidString(message) || strings.ContainsAny(message, "\x00\r\n") || utf8.RuneCountInString(message) > 240 { + return false + } + lower := strings.ToLower(message) + for _, marker := range []string{ + "http://", "https://", "ftp://", "file://", "authorization", "cookie", "secret", "token", "api_key", "apikey", "x-api-key", + "/var/", "/tmp/", "\\", "stack trace", "traceback", " at ", "sql:", "sqlite", "postgres", + } { + if strings.Contains(lower, marker) { + return false + } + } + return true +} + +// cloudAgentSafeMediaTaskError preserves a short, user-facing task diagnostic +// while refusing provider details that commonly contain URLs, credentials, or +// internal request metadata. Task.Error is not a safe presentation field. +func cloudAgentSafeMediaTaskError(task *model.Task) string { + if task == nil || strings.TrimSpace(task.Error) == "" { + return "媒体任务未成功" + } + // 与画布节点展示同一套分类:网络/审核/存储/HTTP 状态归类,可读的供应商原因原样保留。 + return userFacingTaskError(task.Error) +} diff --git a/backend/internal/app/cloud_agent_runtime_media.go b/backend/internal/app/cloud_agent_runtime_media.go new file mode 100644 index 000000000..9f2b643f7 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_media.go @@ -0,0 +1,705 @@ +// 画布 Agent 的媒体生成:提交任务、等待结果、处理审批。 +// +// 涉及扣费的 generate_media 必须先经用户审批(DecideCloudAgentApproval), +// 审批通过后才真正创建任务;用户直接在节点上生成时审批会被关闭为 superseded_by_node。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "math" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +// image_layer_split deliberately reuses the canonical media admission path. +// Keeping the alias at this boundary preserves one approval, billing and +// write-back implementation while exposing a task-specific Agent affordance. +func cloudAgentMediaCall(call cloudAgentCall) cloudAgentCall { + if call.Function.Name != "image_layer_split" { + return call + } + var args map[string]any + if err := json.Unmarshal([]byte(call.Function.Arguments), &args); err == nil { + args["mode"] = "image" + if raw, err := json.Marshal(args); err == nil { + call.Function.Arguments = string(raw) + } + } + return call +} + +func (s *Service) enqueueCloudAgentTask(run *model.CloudAgentExecution, state *cloudAgentRuntime, req CreateTaskRequest, media *cloudAgentMediaPlan) error { + orders, err := s.repo.BillingOrdersByTaskIDs(run.UserID, state.TaskIDs) + if err != nil { + return err + } + remaining := int64(math.Floor(state.Request.Budget.MaxCredits * float64(CreditScale))) + for _, order := range orders { + remaining -= order.AmountMicrocredits + } + if remaining < 0 { + return s.failCloudAgent(run, state, "Agent 累计预算已耗尽") + } + var prepared *cloudAgentPreparedMedia + approvalID, generationID := "", "" + if media != nil { + prepared = media.Prepared + if prepared == nil && state.Approval != nil { + prepared = state.Approval.Prepared + if state.Approval != nil { + approvalID = state.Approval.ID + } + } + if prepared == nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, creationConflict("缺少已批准的生成准备态,请重新申请审批;未提交任务")) + } + generationID = prepared.GenerationID + refs, refErr := cloudAgentPreparedReferences(s.repo, run.UserID, prepared) + if refErr != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, refErr) + } + if req.Input == nil { + req.Input = map[string]any{} + } + for _, field := range []string{"referenceImages", "referenceVideos", "referenceAudios"} { + delete(req.Input, field) + } + for field, value := range refs { + req.Input[field] = value + } + } + prepare := &creationTaskPreparation{} + req.admission = &taskAdmission{ID: cloudAgentID(run.UserID, fmt.Sprintf("%s:task:%d", run.ID, len(state.TaskIDs))), MaxCharge: remaining, AgentRunID: run.ID, GenerationID: generationID, ApprovalID: approvalID} + req.creationPrepare = prepare + task, err := s.CreateTask(run.UserID, req) + if err != nil { + if media != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + return s.failCloudAgent(run, state, cloudAgentSafeToolError(err)) + } + var input map[string]any + if err = json.Unmarshal([]byte(task.InputJSON), &input); err != nil { + if media != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + return err + } + if media != nil { + requested, _ := req.Input["config"].(map[string]any) + resolved, _ := input["config"].(map[string]any) + if err := validateCloudAgentResolvedMediaOptions(requested, resolved); err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + } + if err = s.protectTaskSecrets(input); err != nil { + if media != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + return err + } + raw, err := json.Marshal(input) + if err != nil { + if media != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + return err + } + task.InputJSON = string(raw) + // 压力读数只对"模型调用"这一步有意义:媒体任务发的是另一份请求(另一套信封), + // 把读数挂到那份信封上会误导消费方。 + var contextPressure *cloudAgentContextPressure + var requestCanonical canonicalAgentRequest + if media == nil { + if req.Operation != cloudAgentContextCompactionOperation { + canonical, ok := canonicalAgentRequestFromInput(input) + if !ok { + return fmt.Errorf("Agent 模型任务缺少可计量的请求信封") + } + requestCanonical = canonical + value := s.cloudAgentContextPressure(canonical, state.Request.Prompt, state.Request) + contextPressure = &value + } + } + if prepare.Order != nil { + task.BillingOrderID = prepare.Order.ID + } + policy, err := s.RuntimePolicy() + if err != nil { + if media != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + return err + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + err = s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + if media != nil { + canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) + if err != nil { + return err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return err + } + if err := validateCloudAgentPreparedAdmission(repo, run.UserID, prepared, task, prepare.Order, doc, media.Args); err != nil { + return err + } + if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, media, task, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { + return err + } + } + if err := createTaskWithStorageQuotaRepository(repo, task, prepare.Order, policy); err != nil { + return err + } + if media != nil && prepared != nil { + if approvalID != "" { + if err := repo.TransferCloudAgentResourceLeases(run.UserID, approvalID, "task:"+task.ID, task.ID, prepared.Quote.ExpiresAt); err != nil { + return err + } + } else { + // Auto execution has no approval owner to transfer from. Attach the + // resource lease directly to the billed task before it can run. + ids := make([]string, 0, len(prepared.ResourceSignatures)) + for id := range prepared.ResourceSignatures { + ids = append(ids, id) + } + if err := repo.UpsertCloudAgentResourceLeases(run.UserID, run.ID, "task:"+task.ID, ids, prepared.Quote.ExpiresAt); err != nil { + return err + } + } + } + if media != nil { + // The prepared auto admission is single-use. Once the billed task and + // draft node are committed, a later retry must observe the task state, + // not reuse the old quote or create a second task. + state.AutoPreparedMedia = nil + state.AutoPreparedCallHash = "" + } + state.TaskIDs = append(state.TaskIDs, task.ID) + if media != nil { + state.MediaTaskID = task.ID + state.Generations++ + state.VideoSeconds += media.Args.Duration + toolName := "generate_media" + if state.CallIndex >= 0 && state.CallIndex < len(state.Calls) && state.Calls[state.CallIndex].Function.Name != "" { + toolName = state.Calls[state.CallIndex].Function.Name + } + state.event(run.ID, "generation_task_created", map[string]any{"toolName": toolName, "taskId": task.ID, "nodeId": media.Args.NodeID, "title": media.Args.Title, "mode": media.Args.Mode, "canvasId": state.Request.CanvasID, "referenceNodeIds": media.Args.ReferenceNodeIDs, "text": "媒体节点与引用连线已创建,生成任务已提交"}) + } else { + state.ActiveTaskID = task.ID + // 压缩调用不是本轮的一步:压完还要用压缩后的上下文继续步进,步数不该被它占掉。 + if state.ContextCompaction != nil && req.Operation == cloudAgentContextCompactionOperation { + state.ContextCompaction.Status = "running" + } else { + if contextPressure != nil { + config, _ := input["config"].(map[string]any) + model, channelID := stringValue(config["model"]), stringValue(config["channelId"]) + signature := cloudAgentRequestSignature(state, requestCanonical, channelID, model) + cloudAgentExpireTokenAnchorForRequest(run.ID, state, contextPressure.ContextWindowTokens, signature, model, channelID) + cloudAgentRecordMemorySegment(&state.Policy, requestCanonical.SystemPrompt) + payload := cloudAgentContextPressurePayload(*contextPressure, state, requestCanonical) + payload["requestId"] = task.ID + cloudAgentNoteContextWindowResolved(run.ID, state, *contextPressure) + state.event(run.ID, "context_pressure", payload) + state.LastStepTaskID = task.ID + state.LastStepOperation = req.Operation + state.LastStepEstimate = contextPressure.EstimatedInputTokens + state.LastStepSourceBytes = contextPressure.SourceBytes + state.LastStepSignature = signature + state.LastStepModel = model + state.LastStepChannelID = channelID + state.LastStepWindowTokens = contextPressure.ContextWindowTokens + } + state.Step++ + } + } + return cloudAgentSave(current, state) + }) + if err != nil && media != nil { + // Rollback may have happened after checkpoint edits. Reload before recording + // a tool failure; never turn a stale worker revision into a second result. + latest, readErr := s.repo.CloudAgent(run.UserID, run.ID) + if readErr != nil || latest.Revision != run.Revision { + return err + } + fresh, decodeErr := cloudAgentDecode(latest) + if decodeErr != nil { + return decodeErr + } + return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err) + } + return err +} + +func (s *Service) cloudAgentMediaError(run *model.CloudAgentExecution, state *cloudAgentRuntime, phase string, submitted, terminal bool, err error) error { + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { + current.Status = "failed" + state.event(run.ID, "run_failed", map[string]any{"text": "Agent 媒体调用状态无效,本轮已停止"}) + return cloudAgentSave(current, state) + } + if phase == "admission" && !submitted { + err = cloudAgentWrapMediaAdmissionError(err) + } + toolName := state.Calls[state.CallIndex].Function.Name + cloudAgentRecordToolResult(current, state, state.Calls[state.CallIndex], map[string]any{"phase": phase, "taskSubmitted": submitted}, err) + // Any admission failure advances the call into the repair path. Do not let + // a prepared quote from the failed attempt leak into the corrected call. + state.AutoPreparedMedia = nil + state.AutoPreparedCallHash = "" + if submitted { + state.MediaTaskID = "" + } + // Only explicitly typed argument errors and a stale canvas snapshot may + // continue into another model turn. Every other pre-submission media + // failure is a real admission boundary failure; continuing would invite + // the model to submit unverified variants of a billed write. + continueAfterAdmissionError := false + if phase == "admission" && !submitted { + var argumentErr *cloudAgentArgumentError + continueAfterAdmissionError = errors.As(err, &argumentErr) + var admissionErr *cloudAgentMediaAdmissionError + if errors.As(err, &admissionErr) && admissionErr.Reason == "snapshot_conflict" { + continueAfterAdmissionError = true + } + } + if terminal || (phase == "admission" && !submitted && !continueAfterAdmissionError) { + current.Status = "failed" + message := "媒体生成准入失败,本轮已停止;请检查模型、能力和预算后由用户明确重试" + reason := "tool_admission_failed" + if terminal { + message = "媒体任务已提交,但结果处理失败;任务不会自动重试" + reason = "media_task_failed" + } + current.FailureMessage = truncateRunes(message, 1000) + cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(message, 120), state) + state.event(run.ID, "run_failed", map[string]any{"text": message, "reason": reason, "toolName": toolName}) + } + return cloudAgentSave(current, state) + }) +} + +func (s *Service) advanceCloudAgentMedia(run *model.CloudAgentExecution, state *cloudAgentRuntime, call cloudAgentCall) error { + if state.MediaTaskID != "" { + task, err := s.repo.TaskForUser(run.UserID, state.MediaTaskID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("媒体任务不存在或已失去归属,结果未回写画布")) + } + if err != nil { + return err + } + if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning { + return nil + } + policy, err := s.RuntimePolicy() + if err != nil { + return err + } + var target struct { + NodeID string `json:"nodeId"` + } + if err := json.Unmarshal([]byte(call.Function.Arguments), &target); err != nil || validateCloudAgentID(target.NodeID, "生成节点ID", 80) != nil { + return s.cloudAgentMediaError(run, state, "completion", true, true, BadAuthRequest("已提交媒体任务的目标节点记录无效,未回写画布")) + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + before, readErr := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) + nodeID, writeErr := completeCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, target.NodeID, task, policy) + if writeErr != nil { + var appErr *AppError + if !errors.Is(writeErr, gorm.ErrRecordNotFound) && !(errors.As(writeErr, &appErr) && (appErr.Status == 400 || appErr.Status == 409)) { + return writeErr // Retry persistence, never resubmit the billed generation. + } + } + // A deleted canvas/node must still checkpoint the tool failure. Only + // a matched node can have changed and require an atomic canvas delta. + if nodeID != "" { + if readErr != nil { + return readErr + } + if err := emitCloudAgentCanvasChange(repo, run.ID, state, cloudAgentMutationInput{UserID: run.UserID, CanvasID: state.Request.CanvasID, BeforeJSON: before.PayloadJSON, Operation: "generate_media_complete"}); err != nil { + return err + } + } + result := map[string]any{"phase": "completion", "taskSubmitted": true, "taskId": task.ID, "nodeId": nodeID, "targetNodeId": target.NodeID, "status": task.Status} + var toolErr error + generationMessage := "" + writebackMessage, writebackReason := "", "" + if task.Status != model.TaskStatusSucceeded { + generationMessage = truncateRunes(cloudAgentSafeMediaTaskError(task), 90) + if !cloudAgentSafeUserMessage(generationMessage) { + generationMessage = "媒体任务未成功" + } + result["generationError"] = generationMessage + toolErr = BadAuthRequest("媒体任务未成功:" + generationMessage + ";请在任务中心查看任务详情,不会自动重试收费生成") + result["summary"] = "媒体任务未成功;任务记录保留在任务中心" + } + if writeErr != nil { + writebackMessage = truncateRunes(cloudAgentSafeToolError(writeErr), 60) + writebackReason = "canvas_writeback_failed" + var writeback *cloudAgentMediaWritebackError + if errors.As(writeErr, &writeback) { + writebackReason = writeback.reason + } + result["writebackError"], result["writebackReason"] = writebackMessage, writebackReason + message := "媒体任务已成功,但画布回写未完成:" + writebackMessage + if generationMessage != "" { + message = "媒体任务未成功:" + generationMessage + ";任务状态也未回写画布:" + writebackMessage + } + toolErr = BadAuthRequest(message + "。请在任务中心查看详情,不会自动重试收费生成") + result["summary"] = "画布回写未完成;任务记录保留在任务中心" + } + if task.Status == model.TaskStatusSucceeded && writeErr == nil { + // Completion writes the generated asset into the canvas. Invalidate + // the same-run snapshot cache just like ordinary canvas writes. + cloudAgentInvalidateReadCache(state) + result["summary"] = "生成结果已回写画布节点" + } + if writeErr != nil { + recordTaskWritebackDiagnostic(task, target.NodeID, "failed", writebackReason, writeErr) + } else { + recordTaskWritebackDiagnostic(task, target.NodeID, "succeeded", "", nil) + } + if err := repo.UpdateTaskExecutionDiagnostic(run.UserID, task.ID, task.ExecutionDiagnosticJSON); err != nil { + return err + } + // A billed generation failure is a tool result, not a dead run: the + // model must still be able to tell the user what happened. Only a + // canvas write that cannot land is terminal for the whole turn. + if writeErr != nil { + if current.Status != "cancelled" { + current.Status = "failed" + } + current.FailureMessage = cloudAgentSafeToolError(toolErr) + state.event(run.ID, "run_failed", map[string]any{ + "text": current.FailureMessage, "taskId": task.ID, "nodeId": target.NodeID, + "reason": result["writebackReason"], "generationStatus": task.Status, + "generationError": generationMessage, "writebackError": result["writebackError"], + }) + } + cloudAgentToolResult(run.ID, state, call, result, toolErr) + state.MediaTaskID = "" + return cloudAgentSave(current, state) + }) + } + req, plan, err := s.prepareCloudAgentMedia(run, state, call) + if err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + return s.enqueueCloudAgentTask(run, state, req, plan) +} + +func (s *Service) DecideCloudAgentApproval(userID, id, approvalID, decision, reason string, mediaSettings ...*CloudAgentMediaSettings) error { + // Resource leases are a protection mechanism only. Expiry cleanup is safe to + // run on the control-plane path and never changes the approval decision. + if err := s.repo.ReleaseExpiredCloudAgentResourceLeases(time.Now().UTC()); err != nil { + return err + } + var settings *CloudAgentMediaSettings + if len(mediaSettings) > 1 { + return BadAuthRequest("只能提交一组生成参数") + } + if len(mediaSettings) == 1 { + settings = mediaSettings[0] + } + if settings != nil && decision != "approve" { + return BadAuthRequest("仅批准生成时可修改生成参数") + } + if decision != "approve" && decision != "reject" { + return BadAuthRequest("无效审批决定") + } + if len(reason) > 2000 { + return BadAuthRequest("审批理由过长") + } + if _, _, err := s.cloudAgentTask(userID, id); err != nil { + return err + } + run, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + state, err := cloudAgentDecodeForExecution(run) + if err != nil { + return err + } + if previous, ok := state.Decisions[approvalID]; ok { + if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { + return nil + } + return cloudAgentDecisionConflict(previous) + } + if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { + return creationConflict("审批不存在或已过期") + } + // 只在记录审批决定的这次写入期间持锁:随后的工具执行(advanceCloudAgentTool) + // 会自己获取 storageMu,持锁到函数返回会造成自锁。旧 Pi 会话退出前仍可能 + // 写入最后一个事件,因此审批决定采用重读后 CAS 重试。 + for attempt := 0; attempt < 8; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) + run, err = s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + state, err = cloudAgentDecodeForExecution(run) + if err != nil { + return err + } + if previous, ok := state.Decisions[approvalID]; ok { + if previous == decision && (settings == nil || state.DecisionSettings[approvalID] == creationHash(settings)) { + return nil + } + return cloudAgentDecisionConflict(previous) + } + if run.Status != "waiting_approval" || state.Approval == nil || state.Approval.ID != approvalID { + return creationConflict("审批不存在或已过期") + } + } + s.storageMu.Lock() + err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + if settings != nil { + if err := s.updateCloudAgentMediaApproval(repo, run, &state, *settings); err != nil { + return err + } + } + state.Approval.Decision = decision + state.Approval.Reason = reason + state.Decisions[approvalID] = decision + if settings != nil { + if state.DecisionSettings == nil { + state.DecisionSettings = map[string]string{} + } + state.DecisionSettings[approvalID] = creationHash(settings) + } + if decision == "approve" && state.Approval.Prepared != nil { + if state.DecisionPreparedHashes == nil { + state.DecisionPreparedHashes = map[string]string{} + } + state.DecisionPreparedHashes[approvalID] = state.Approval.Prepared.Hash + } + if decision == "reject" { + // Rejection is a user control-plane decision, not a failed tool + // invocation. Make it terminal before the scheduler can advance the + // pending call; no tool result, canvas mutation, generation task or + // follow-up model request may be produced from this decision. + current.Status = "rejected" + current.FailureMessage = "" + state.Approval = nil + state.event(id, "approval_decided", map[string]any{ + "approvalId": approvalID, + "decision": decision, + "reason": reason, + "text": "已拒绝本次生成,草稿节点仍保留在画布中;未提交任务、未产生扣费。你可以继续编辑后重新申请。", + }) + if err := repo.ReleaseCloudAgentResourceLeases(userID, approvalID); err != nil { + return err + } + return cloudAgentSave(current, &state) + } + current.Status = "running" + payload := map[string]any{"approvalId": approvalID, "decision": decision, "arguments": json.RawMessage(state.Approval.Call.Function.Arguments), "preview": state.Approval.Preview, "modelName": state.Approval.ModelName} + if state.Approval.Prepared != nil { + payload["preparedHash"] = state.Approval.Prepared.Hash + payload["generationId"] = state.Approval.Prepared.GenerationID + } + state.event(id, "approval_decided", payload) + return cloudAgentSave(current, &state) + }) + s.storageMu.Unlock() + if !errors.Is(err, repository.ErrCreationConflict) { + break + } + } + if err != nil || decision != "approve" { + return err + } + // Approval only releases the paused tool. Execute it once in the Go business + // executor, then give the durable result back to Pi; Go never asks a model + // what to do next. + var mediaTaskID string + for attempt := 0; attempt < 8; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) + } + latest, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + state, err = cloudAgentDecode(latest) + if err != nil { + return err + } + err = s.advanceCloudAgentTool(latest, &state) + if !errors.Is(err, repository.ErrCreationConflict) { + if err != nil { + return err + } + mediaTaskID = state.MediaTaskID + break + } + if attempt == 7 { + return err + } + } + if mediaTaskID == "" { + return s.resumeCloudAgentAfterApproval(userID, id, &state) + } + // 媒体生成可能要几分钟:审批请求立即返回,等待与回写在后台完成后再恢复运行。 + s.startApprovedCloudAgentMediaWaiter(userID, id, mediaTaskID) + return nil +} + +func (s *Service) finishApprovedCloudAgentMedia(ctx context.Context, userID, id, mediaTaskID string) error { + mediaTask, err := s.waitCloudAgentTask(ctx, mediaTaskID) + if err != nil && ctx.Err() != nil { + // 服务停机或等待被取消:不能继续回写和恢复运行时,否则正在排空的进程会重新拉起 Agent。 + // MediaTaskID 仍保留在运行状态里,重启后 recoverCloudAgentPiRunners 会重新挂上等待。 + return ctx.Err() + } + if mediaTask == nil { + // 失败的任务也要回写(记录工具失败并释放 MediaTaskID);只有读不到任务才中止。 + if mediaTask, err = s.repo.Task(mediaTaskID); err != nil { + return err + } + } + if err = s.settleCloudAgentMedia(userID, id); err != nil { + return err + } + latest, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + state, err := cloudAgentDecode(latest) + if err != nil { + return err + } + if mediaTask.Status != model.TaskStatusSucceeded { + state.PiResumePrompt = "用户已批准该操作,但媒体任务未成功完成。请根据工具结果告知用户,不要重复提交该操作。" + } + return s.resumeCloudAgentAfterApproval(userID, id, &state) +} + +func (s *Service) resumeCloudAgentAfterApproval(userID, id string, state *cloudAgentRuntime) error { + if state.PiResumePrompt == "" { + state.PiResumePrompt = "用户已批准刚才等待审批的操作。业务执行器已执行一次;请根据最新工具结果继续,不要重复调用该操作。" + } + if err := s.saveCloudAgentPiResumePrompt(userID, id, state.PiResumePrompt); err != nil { + return err + } + s.resumeCloudAgentPi(userID, id) + return nil +} + +// saveCloudAgentPiResumePrompt 只合并恢复提示词,不覆盖其它并发写入的字段。 +func (s *Service) saveCloudAgentPiResumePrompt(userID, id, prompt string) error { + for attempt := 0; attempt < 8; attempt++ { + run, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + fresh, err := cloudAgentDecode(current) + if err != nil { + return err + } + fresh.PiResumePrompt = prompt + return cloudAgentSave(current, &fresh) + }) + if !errors.Is(err, repository.ErrCreationConflict) { + return err + } + } + return repository.ErrCreationConflict +} + +func (s *Service) CancelCloudAgent(ctx context.Context, userID, id string) error { + s.stopCloudAgentPi(id) + // Cancellation is a control-plane operation. It must remain available even + // when the user-facing runtime blob is damaged, so authenticate/authorize + // from the task row first instead of calling CloudAgentRun up front. + task, err := s.repo.TaskForUser(userID, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return kernel.NotFound("Agent 运行不存在") + } + return err + } + if task.Operation != cloudAgentOperation { + return kernel.NotFound("Agent 运行不存在") + } + run, err := s.repo.CloudAgent(userID, id) + if errors.Is(err, gorm.ErrRecordNotFound) { + // Legacy root tasks may not have an execution row yet. The normal read + // path validates the signed/deterministic task identity before creating it. + if _, err = s.CloudAgentRun(userID, id); err != nil { + return err + } + run, err = s.repo.CloudAgent(userID, id) + } + if err != nil { + return err + } + if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) { + return nil + } + // 取消是用户操作,不能因为运行时正在并发写事件就失败:冲突时重读最新 revision 重试。 + for attempt := 0; run.Status != "failed" && attempt < 8; attempt++ { + if attempt > 0 { + time.Sleep(time.Duration(attempt) * 20 * time.Millisecond) + if run, err = s.repo.CloudAgent(userID, id); err != nil { + return err + } + if run.Status == "completed" || (run.Status == "failed" && !run.CleanupPending) { + return nil + } + } + // Persist intent independently of the transcript. Retrying also repairs + // legacy cancelled rows that crashed before cancelling their children. + state, decodeErr := cloudAgentDecode(run) + err = s.repo.MutateCloudAgent(userID, id, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + firstCancellation := current.Status != "cancelled" + current.Status = "cancelled" + current.CleanupPending = true + if decodeErr == nil { + current.CanvasID, current.ActiveTaskID, current.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID + if firstCancellation { + state.event(id, "run_cancelled", map[string]any{"source": "user_request", "activeTaskId": state.ActiveTaskID, "mediaTaskId": state.MediaTaskID, "text": "用户取消接口已接收请求,正在取消关联任务"}) + if saveErr := cloudAgentSave(current, &state); saveErr != nil { + // Cancellation must still work if the transcript is oversized. + log.Printf("[cloud-agent] cancellation event unavailable for run %s: checkpoint rejected", id) + } + } + } else { + log.Printf("[cloud-agent] cancellation event unavailable for run %s: invalid transcript", id) + } + return nil + }) + if errors.Is(err, repository.ErrCreationConflict) { + continue + } + if err != nil { + return err + } + break + } + if errors.Is(err, repository.ErrCreationConflict) { + return err + } + latest, err := s.repo.CloudAgent(userID, id) + if err != nil { + return err + } + return s.finishCloudAgentCleanup(ctx, latest) +} diff --git a/backend/internal/app/cloud_agent_runtime_scheduler.go b/backend/internal/app/cloud_agent_runtime_scheduler.go new file mode 100644 index 000000000..1ddb229b9 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_scheduler.go @@ -0,0 +1,538 @@ +// 画布 Agent 的调度循环:领取待推进的运行、执行一步、写回检查点。 +// +// 调度器按 run 粒度串行推进(乐观锁 revision + CAS);同一轮的模型调用、工具执行、 +// 媒体提交都拆成可重入的小步,进程在任意一步退出后都能从最新检查点继续。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "unicode/utf8" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func (s *Service) cloudAgentExecutionOutput(task *model.Task, initial cloudAgentState, options ...CloudAgentRunViewOptions) (*CloudAgentRun, error) { + run, err := s.repo.CloudAgent(task.UserID, task.ID) + if err != nil { + return nil, err + } + state, stateErr := cloudAgentDecode(run) + if stateErr != nil { + // A terminal failed run must remain readable even if its durable runtime + // blob was damaged. Do not invent permissions or approval state; expose + // only the identity available from the original task input. + state = cloudAgentRuntime{Request: initial.Request, ParentID: initial.ParentID, CreativeAnchor: initial.CreativeAnchor, Skills: initial.Skills, Profile: initial.Profile, TaskIDs: []string{task.ID}, Events: []CloudAgentEvent{}} + } + out := agentRunOutput(task, initial) + out.Status = run.Status + out.Revision, out.CleanupPending, out.FailureMessage = run.Revision, run.CleanupPending, run.FailureMessage + out.UpdatedAt = run.UpdatedAt + view := CloudAgentRunViewOptions{} + if len(options) > 0 { + view = options[0] + } + // 事件已全量落库,运行详情只返回一页:默认是尾部窗口,sinceSeq 只取增量。 + // 四个位置字段与 events 一起返回,客户端据此判断"是否还有更早的记录"。 + out.Events = s.cloudAgentRunEventsForView(task.UserID, run, &state, view.SinceSeq, view.EventLimit) + // EventSeqBase 必须与真正返回的这一页对齐(eventLimit 把它收窄时也一样), + // 不变量 events[i].seq == eventSeqBase + i + 1 才成立。页为空时退回窗口水位: + // 那时没有"首条事件",但仍然要能说明窗口在整条日志里的位置。 + out.EventSeqBase = state.EventSeqBase + if len(out.Events) > 0 { + out.EventSeqBase = out.Events[0].Seq - 1 + } + out.EventCount = s.cloudAgentRunEventCount(task.UserID, run, &state) + if len(out.Events) > 0 { + out.LatestSeq = out.Events[len(out.Events)-1].Seq + } + if view.SinceSeq == 0 && len(out.Events) < out.EventCount { + out.EventsTruncated = true + } + out.Approval = state.Approval + if cloudAgentRunTerminal(run.Status) { + out.Approval = nil + } + out.Step = state.Step + if stateErr == nil && state.ActiveTaskID != "" && (run.Status == "running" || run.Status == "queued") { + active, err := s.repo.TaskForUser(task.UserID, state.ActiveTaskID) + if err != nil { + return nil, err + } + if active.TextDraft != "" { + out.ActiveMessage = map[string]string{"messageId": active.ID, "text": active.TextDraft} + } + } + out.Skills = make([]cloudAgentSkill, 0, len(state.Skills)) + for _, skill := range state.Skills { + skill.Instruction = "" + skill.Files = nil + out.Skills = append(out.Skills, skill) + } + orders, err := s.repo.BillingOrdersByTaskIDs(task.UserID, state.TaskIDs) + if err != nil { + return nil, err + } + for _, order := range orders { + // AmountMicrocredits is the reservation/quote, not necessarily the amount + // finally charged. A run can reserve 100000 microcredits and settle at + // 100000 microcredits (= 0.1 credits), or be refunded altogether. Never + // expose the reservation as spend, otherwise the Agent claims a charge + // that did not happen and budget/usage copy diverges from billing. + if order.Status == model.BillingStatusSettled { + out.SpentCredits += float64(order.ActualAmountMicrocredits) / float64(CreditScale) + } + } + return out, nil +} + +// Runs one bounded transition at a time; no model HTTP call or approval wait holds a DB lock. +func (s *Service) advanceCloudAgentByID(userID, id string) error { + // Do not decode the task input/runtime before checking for an existing + // execution. A damaged runtime must be terminally recoverable, not + // accidentally replaced by a fresh execution row. + task, err := s.repo.TaskForUser(userID, id) + if err != nil { + return err + } + if task.Operation != cloudAgentOperation { + return kernel.NotFound("Agent 运行不存在") + } + run, lookupErr := s.repo.CloudAgent(userID, id) + if errors.Is(lookupErr, gorm.ErrRecordNotFound) { + _, initial, taskErr := s.cloudAgentTask(userID, id) + if taskErr != nil { + return taskErr + } + if err := s.ensureCloudAgentExecution(task, initial); err != nil { + return err + } + run, lookupErr = s.repo.CloudAgent(userID, id) + } + if lookupErr != nil { + return lookupErr + } + return s.advanceCloudAgent(run) +} + +func (s *Service) advanceCloudAgents() { + s.agentSchedulerMu.Lock() + defer s.agentSchedulerMu.Unlock() + roots, err := s.repo.CloudAgentRoots() + if err != nil { + log.Printf("agent recovery: %v", err) + return + } + for _, task := range roots { + _, state, e := s.cloudAgentTask(task.UserID, task.ID) + if e == nil { + e = s.ensureCloudAgentExecution(&task, state) + } + if e != nil { + log.Printf("agent recovery %s: %v", task.ID, e) + } + } + runs, err := s.repo.ActiveCloudAgentsAfter(s.agentSchedulerCursor, 50) + if err == nil && len(runs) == 0 && s.agentSchedulerCursor != "" { + s.agentSchedulerCursor = "" + runs, err = s.repo.ActiveCloudAgentsAfter("", 50) + } + if err != nil { + log.Printf("agent scheduler: %v", err) + return + } + for i := range runs { + s.agentSchedulerCursor = runs[i].ID + if s.terminateStuckCloudAgent(&runs[i]) { + continue + } + err = s.advanceCloudAgent(&runs[i]) + if err == nil { + s.clearCloudAgentSchedulerConflict(runs[i].ID) + continue + } + if errors.Is(err, repository.ErrCreationConflict) { + s.noteCloudAgentSchedulerConflict(runs[i].ID) + continue + } + log.Printf("agent transition %s: %v", runs[i].ID, err) + } +} + +// wakeCloudAgentScheduler lets a completed model step resume its Agent without +// waiting for the periodic recovery scan. The ticker remains authoritative for +// other workers and missed in-process notifications. +func (s *Service) wakeCloudAgentScheduler() { + if s == nil || s.agentSchedulerWake == nil { + return + } + select { + case s.agentSchedulerWake <- struct{}{}: + default: + } +} + +func (s *Service) advanceCloudAgent(run *model.CloudAgentExecution) (err error) { + defer func() { + if errors.Is(err, errCloudAgentCheckpoint) { + message := "Agent 运行状态保存失败,本轮已停止;已有任务结果保留在任务中心" + var checkpointErr *cloudAgentCheckpointError + if errors.As(err, &checkpointErr) { + log.Printf("agent checkpoint rejected run=%s stage=%s error=%v", run.ID, checkpointErr.Stage, checkpointErr.Err) + if checkpointErr.Stage == "state size" { + message = "Agent 上下文或执行记录超过安全限制,本轮已停止;已有任务结果保留在任务中心" + } + } else { + message = "Agent 运行状态保存失败,本轮已停止;已有任务结果保留在任务中心" + } + err = s.terminateCloudAgent(run, message) + } + }() + if run.CleanupPending { + return s.finishCloudAgentCleanup(context.Background(), run) + } + if run.Status != "running" && run.Status != "queued" { + return nil + } + state, err := cloudAgentDecodeForExecution(run) + if err != nil { + var appErr *AppError + if errors.As(err, &appErr) && appErr != nil { + return s.terminateCloudAgent(run, appErr.Message) + } + return s.terminateCloudAgent(run, "Agent 运行状态损坏,本轮已停止") + } + // 单步边界每次推进都重新解析:管理员改配置后,正在跑的这一轮下一步就用新值。 + state.StepLimits, err = s.cloudAgentStepLimits() + if err != nil { + return err + } + if state.ActiveTaskID != "" { + task, err := s.repo.TaskForUser(run.UserID, state.ActiveTaskID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return s.terminateCloudAgent(run, "Agent 模型任务已不存在,本轮已停止") + } + if err != nil { + return err // Transient database failures must not terminate a live task. + } + // 停在压缩上时,这个在跑的任务就是压缩调用:它的结果只用来生成检查点, + // 不走"正文/工具调用"那套解析,也不会计入步数。 + if state.ContextCompaction != nil { + return s.advanceCloudAgentContextCompaction(run, &state, task) + } + if task.Status == model.TaskStatusQueued || task.Status == model.TaskStatusRunning { + // 将已持久化的模型增量转成 Agent 事件;不拆分完整答案伪装成流式。 + if task.TextDraft != state.ActiveTextDraft { + delta := "" + eventType := "assistant_snapshot" + payload := map[string]any{"messageId": task.ID, "text": task.TextDraft, "replace": true} + if strings.HasPrefix(task.TextDraft, state.ActiveTextDraft) { + delta = strings.TrimPrefix(task.TextDraft, state.ActiveTextDraft) + if delta == "" { + return nil + } + eventType = "assistant_delta" + payload = map[string]any{"messageId": task.ID, "text": delta} + } + if err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + state.event(run.ID, eventType, payload) + state.ActiveTextDraft = task.TextDraft + return cloudAgentSave(current, &state) + }); err != nil { + return err + } + } + return nil + } + var result struct { + Text string `json:"text"` + Reasoning string `json:"reasoning"` + ToolCalls []cloudAgentCall `json:"toolCalls"` + Legacy []cloudAgentCall `json:"tool_calls"` + } + if task.Status == model.TaskStatusSucceeded { + if err := json.Unmarshal([]byte(task.ResultJSON), &result); err != nil { + return s.terminateCloudAgent(run, "模型任务结果损坏,本轮已停止") + } + calls := result.ToolCalls + if len(calls) == 0 { + calls = result.Legacy + } + if violation := cloudAgentOutputViolation(result.Text, len(calls)); violation != "" { + return s.correctCloudAgentOutput(run, &state, violation) + } + if err := validateCloudAgentCalls(calls); err != nil { + return s.correctCloudAgentOutput(run, &state, "工具调用无效或重复(callId 不能重复、参数必须是 JSON 对象)") + } + result.ToolCalls = calls + } + if task.Status != model.TaskStatusSucceeded && cloudAgentTruncatedToolArguments(task) { + return s.correctCloudAgentTruncatedCalls(run, &state) + } + if cloudAgentEmptyModelOutput(task) { + if state.EmptyOutputNudged < cloudAgentMaxEmptyOutputNudges { + return s.correctCloudAgentEmptyOutput(run, &state) + } + // 催过仍然空:改为"关思考 + 放大输出预算"重试同一步,而不是把整轮判死。 + if state.EmptyOutputEscalated < cloudAgentMaxEmptyOutputEscalations { + return s.correctCloudAgentEmptyOutputEscalation(run, &state) + } + } + // 单步墙钟到点同样是可恢复失败:关思考重试一次,而不是把整轮判死。 + if cloudAgentStepTimedOut(task) && state.StepTimeoutEscalated < cloudAgentMaxStepTimeoutEscalations { + return s.correctCloudAgentStepTimeout(run, &state) + } + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + if task.Status != model.TaskStatusSucceeded { + current.Status = "failed" + text, reason := cloudAgentModelFailure(task) + current.FailureMessage = truncateRunes(text, 1000) + cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(text, 120), &state) + state.event(run.ID, "run_failed", map[string]any{"text": text, "reason": reason, "taskId": task.ID}) + return cloudAgentSave(current, &state) + } + calls := result.ToolCalls + if result.Reasoning != "" { + state.event(run.ID, "reasoning_message", map[string]any{"messageId": task.ID + ":reasoning", "text": truncateRunes(result.Reasoning, 8000)}) + } + if result.Text != "" { + state.event(run.ID, "assistant_message", map[string]any{"messageId": task.ID, "text": result.Text}) + // 普通正文可能是读取失败或多图混合回答,不能自动归为某张图的视觉事实。 + if len(calls) == 0 { + state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text}) + } + } + state.ActiveTaskID = "" + if len(calls) > 0 || strings.TrimSpace(result.Text) != "" { + state.EmptyOutputNudged = 0 + } + state.Canonical.ToolChoice = "auto" + state.Calls = calls + state.CallIndex = 0 + state.StepSnapshotHash = cloudAgentCaptureStepSnapshotHash(calls) + if len(calls) > 0 { + state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "assistant", "content": result.Text, "tool_calls": calls}) + } + if len(calls) == 0 { + if len(state.PendingInterjections) > 0 { + if !cloudAgentStepBudgetExhausted(&state) { + return cloudAgentSave(current, &state) + } + cloudAgentDropInterjections(run.ID, "本轮已达到模型调用上限", &state) + } + if !cloudAgentStepBudgetExhausted(&state) && !state.ActionNudged { + if pending := cloudAgentPendingPlanItems(state.Plan); len(pending) > 0 { + state.ActionNudged = true + state.Canonical.Messages = append(state.Canonical.Messages, cloudAgentPlanNudgeMessage(&state, pending[0])) + return cloudAgentSave(current, &state) + } + } + current.Status = "completed" + } + return cloudAgentSave(current, &state) + }) + } + if state.CallIndex < len(state.Calls) { + if handled, err := s.advanceCloudAgentReadBatch(run, &state); handled { + return err + } + return s.advanceCloudAgentTool(run, &state) + } + // 兜底 flush:本批调用都执行完了(无论最后一个调用是不是看图、有没有被中断), + // 缓冲里的图片必须在这里合并成一条 user 消息落到全部 tool 结果之后。少了这一步, + // "看图不是最后一个调用"的批次会把图片永久丢掉,而对应的 tool 回执已经在历史里。 + // 幂等:正常路径(最后一个调用就是看图)已经在 cloudAgentRecordToolResult 里 flush 过。 + cloudAgentFlushPendingImages(&state) + // 已经请求过压缩:这次推进只负责把压缩调用发出去(它不计入步数,见 enqueueCloudAgentTask)。 + if state.ContextCompaction != nil && state.ContextCompaction.Status == "requested" { + return s.enqueueCloudAgentContextCompaction(run, &state) + } + contextBudget := s.cloudAgentContextBudgetForRequest(state.Request) + if compactCloudAgentContext(&state.Canonical, contextBudget) { + // Evicted read bodies must be obtainable again after compaction. + state.ProfileReads = nil + } + if stepLimit := cloudAgentStepLimit(state.Request); stepLimit > 0 && state.Step >= stepLimit { + return s.failCloudAgent(run, &state, fmt.Sprintf("达到 %d 次模型调用上限,本轮已停止", stepLimit)) + } + cloudAgentDrainInterjections(run.ID, &state) + // 上一步的模型调用已经回来,先用它的上游实测用量更新压力锚点,再发下一步: + // 下一步的读数与后面的正文裁剪判定都要用到这份锚点。 + s.recordCloudAgentTokenAnchor(run.UserID, &state) + // 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。 + // 它必须在压缩判定之前跑:图片是最贵的一类内容,先移出再评估 token 压力才有意义。 + if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, nil); changed { + state.event(run.ID, "context_images_pruned", map[string]any{ + "prunedImages": pruned, "retentionRounds": cloudAgentImageRetentionRounds, + "text": "图片裁剪:移出超出保留轮次的看图结果", + }) + } + canonical, contextErr := s.cloudAgentModelContext(run, &state, contextBudget) + if contextErr != nil { + // 真实任务帧与用户要求有时在下一步建模时就超过输入预算; + // 它会先于下方常规 token 判据失败,仍需给语义压缩一次机会。 + if errors.Is(contextErr, errCloudAgentContextOverBudget) { + if requested, err := s.cloudAgentRequestCompaction(run, &state, contextBudget, contextBudget.CompactAtTokens); err != nil || requested { + return err + } + } + var appErr *AppError + if errors.As(contextErr, &appErr) { + return s.failCloudAgent(run, &state, appErr.Message) + } + return contextErr + } + s.attachCloudAgentLessons(&canonical, run.UserID, cloudAgentLessonTaskText(&state)) + references, refErr := s.cloudAgentImageReferences(run.UserID, state.Request, &canonical) + if refErr != nil { + return s.failCloudAgent(run, &state, cloudAgentSafeToolError(refErr)) + } + // 单步输出上限与思考开关:默认按策略给每一步带上界(不带上界时上游按剩余上下文放行, + // 思考模型可以把单步拖到几分钟);空输出升级重试时改为关思考 + 放大预算。 + stepThinking := cloudAgentReasoningEnabled(state.Policy.ReasoningMode) && !state.ForceThinkingOff + stepOutputTokens := cloudAgentStepOutputBudget(state.StepLimits, state.BoostStepOutputBudget) + input := map[string]any{"mode": "text", "prompt": state.Request.Prompt, "agentRequests": map[string]any{"canonical": canonical}, "config": map[string]any{"channelId": state.Request.ChannelID, "channelModelKey": state.Request.ChannelModelKey, "model": firstNonEmpty(state.Request.ChannelModelKey, state.Request.Model)}, "textOptions": map[string]any{"stream": true, "thinking": stepThinking, "maxOutputTokens": stepOutputTokens}} + if len(references) > 0 { + input["referenceImages"] = references + } + tokens, tokenErr := cloudAgentRequestEstimatedTokens(&canonical) + if tokenErr != nil { + return s.failCloudAgent(run, &state, "模型上下文估算失败,请稍后重试") + } + // 超预算不再直接判死:先暂停步进、把历史压成结构化检查点,压完用压缩后的上下文继续本轮。 + // 次数上限(cloudAgentMaxCompactionsPerRun)用完仍超预算时,才回到下面的判死路径。 + // 分工:这是轮内的**语义压缩**(触发者是 token 线,或没配窗口时的字节/条数兜底); + // compactCloudAgentContext 是轮内可重读正文的**就地卸载**,跨轮 textHistory 由 + // trimCloudAgentTextHistory 兜底——三者对象不同、不会互相打架。 + if requested, err := s.cloudAgentRequestCompaction(run, &state, contextBudget, tokens); err != nil || requested { + return err + } + if tokens > contextBudget.InputBudgetTokens { + return s.failCloudAgent(run, &state, cloudAgentContextBudgetMessage(contextBudget)) + } + req := CreateTaskRequest{ProjectID: state.Request.CanvasID, Type: "canvas_text", Operation: "cloud_agent_step", Prompt: state.Request.Prompt, Model: state.Request.Model, LogicalModelID: state.Request.LogicalModelID, Input: input} + return s.enqueueCloudAgentTask(run, &state, req, nil) +} + +func compactCloudAgentContext(request *canonicalAgentRequest, budget cloudAgentContextBudget) bool { + if request == nil { + return false + } + tokens, err := cloudAgentRequestEstimatedTokens(request) + if err != nil || tokens < budget.CompactAtTokens { + return false + } + // Retain the latest complete tool turn. Never remove call/result envelopes, + // user instructions, call arguments or write receipts to fabricate a summary. + cut := len(request.Messages) - 1 + for cut > 0 && stringField(request.Messages[cut], "role") == "tool" { + cut-- + } + changed := false + for _, message := range request.Messages[:max(0, cut)] { + if stringField(message, "role") != "tool" { + continue + } + var result map[string]any + if json.Unmarshal([]byte(stringField(message, "content")), &result) != nil || result["contextCompacted"] == true { + continue + } + // Only omit re-readable bodies. Preserve IDs, errors, generation status, + // approvals and all other structured facts verbatim. + omitted := false + if nodes, ok := result["nodes"].([]any); ok { + facts := make([]map[string]any, 0, len(nodes)) + for _, value := range nodes { + if node, ok := value.(map[string]any); ok { + fact := map[string]any{"nodeId": node["id"]} + for _, key := range []string{"generation", "generationDraft", "outputReference"} { + if value, exists := node[key]; exists { + fact[key] = value + } + } + if len(fact) > 1 { + facts = append(facts, fact) + } + } + } + if len(facts) > 0 { + result["observedFacts"] = facts + } + } + for _, key := range []string{"content", "nodes"} { + if _, exists := result[key]; exists { + delete(result, key) + omitted = true + } + } + if !omitted { + continue + } + result["contextCompacted"] = true + result["guidance"] = "历史读取正文已移出模型上下文;需要时重新读取。保留的历史状态不是当前状态,也不是执行授权,不得据此重复提交生成。" + body, err := json.Marshal(result) + if err != nil || len(body) >= len(stringField(message, "content")) { + continue + } + message["content"] = string(body) + changed = true + } + return changed +} + +func validateCloudAgentCalls(calls []cloudAgentCall) error { + seen := make(map[string]bool, len(calls)) + for _, call := range calls { + if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || seen[call.ID] || call.Function.Name == "" || len(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) { + return errors.New("invalid Agent tool call") + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil || len(call.Function.Arguments) > 32000 { + return errors.New("invalid Agent tool arguments") + } + seen[call.ID] = true + } + return nil +} + +func (s *Service) terminateCloudAgent(run *model.CloudAgentExecution, message string) error { + if run == nil { + return errors.New(message) + } + if err := s.repo.MarkCloudAgentFailed(run.UserID, run.ID, run.Revision, message); err != nil && !errors.Is(err, repository.ErrCreationConflict) { + return err + } + return nil +} + +func (s *Service) failCloudAgent(run *model.CloudAgentExecution, state *cloudAgentRuntime, message string) error { + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + current.Status = "failed" + current.FailureMessage = truncateRunes(message, 1000) + cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(message, 120), state) + state.event(run.ID, "run_failed", map[string]any{"text": message}) + return cloudAgentSave(current, state) + }) +} + +// failCloudAgentAdmission records deterministic tool admission failures in the +// same checkpoint transaction. Transient repository errors must still escape +// the caller so the scheduler can retry them. +func failCloudAgentAdmission(current *model.CloudAgentExecution, state *cloudAgentRuntime, runID string, err error) error { + message := cloudAgentSafeToolError(err) + current.Status = "failed" + current.FailureMessage = message + state.Approval = nil + state.event(runID, "run_failed", map[string]any{ + "text": message, + "reason": "tool_admission_failed", + }) + return cloudAgentSave(current, state) +} diff --git a/backend/internal/app/cloud_agent_runtime_state.go b/backend/internal/app/cloud_agent_runtime_state.go new file mode 100644 index 000000000..31d8de4c5 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_state.go @@ -0,0 +1,533 @@ +// 画布 Agent 运行状态的编解码与校验。 +// +// cloudAgentRuntime 整体序列化进 CloudAgentExecution.StateJSON;每次写回都经过 +// cloudAgentSave → validateCloudAgentRuntime,保证数据库里不会出现结构上自相矛盾的检查点。 +// 读取缓存等可再生数据在写入前裁剪(cloudAgentPruneReadCacheForCheckpoint), +// 防止状态随轮次无限膨胀。 + +package app + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/prompts" +) + +func cloudAgentDecode(run *model.CloudAgentExecution) (cloudAgentRuntime, error) { + var state cloudAgentRuntime + if run == nil || strings.TrimSpace(run.StateJSON) == "" { + return state, errors.New("Agent runtime state is empty") + } + if err := json.Unmarshal([]byte(run.StateJSON), &state); err != nil { + return state, fmt.Errorf("decode Agent runtime state: %w", err) + } + state.RuntimeRunID = run.ID + if run.CheckpointVersion >= 2 { + if len(run.Transcript) != run.MessageCount { + return state, errors.New("Agent execution transcript is incomplete") + } + // 事件只载入最近一窗(repository.CloudAgentJournalWindow),所以这里校验的是 + // "窗口与水位自洽"而不是全量条数: + // - 窗口必须正好结束在水位上(最后一条 seq == EventCount),否则说明有行没读到; + // - 窗口为空时水位只能是 0,否则同样说明行缺失(不能把"一行都没有"当成空日志); + // - EventSeqBase = 水位 - 窗口条数,即窗口之前已入库的条数。 + if len(run.Journal) == 0 && run.EventCount != 0 { + return state, errors.New("Agent execution journal is incomplete") + } + state.EventSeqBase = run.EventCount - len(run.Journal) + if state.EventSeqBase < 0 { + return state, errors.New("Agent execution journal watermark is invalid") + } + state.Events = make([]CloudAgentEvent, 0, len(run.Journal)) + state.Canonical.Messages, state.TextHistory = nil, nil + for _, record := range run.Journal { + expected := state.EventSeqBase + len(state.Events) + 1 + if record.Sequence != expected { + return state, errors.New("Agent event sequence is incomplete") + } + var event CloudAgentEvent + if err := json.Unmarshal([]byte(record.EventJSON), &event); err != nil { + return state, fmt.Errorf("decode Agent event: %w", err) + } + if event.Seq != record.Sequence || event.RunID != run.ID || event.EventID != fmt.Sprintf("%s:%d", run.ID, record.Sequence) { + return state, errors.New("Agent event identity is invalid") + } + state.Events = append(state.Events, event) + } + if len(state.Events) > 0 && state.Events[len(state.Events)-1].Seq != run.EventCount { + return state, errors.New("Agent execution journal is incomplete") + } + for _, record := range run.Transcript { + switch record.Kind { + case "canonical": + var message map[string]any + if err := json.Unmarshal([]byte(record.MessageJSON), &message); err != nil { + return state, fmt.Errorf("decode Agent message: %w", err) + } + if record.Sequence != len(state.Canonical.Messages)+1 { + return state, errors.New("Agent message sequence is incomplete") + } + state.Canonical.Messages = append(state.Canonical.Messages, message) + case "history": + var message providerTextMessage + if err := json.Unmarshal([]byte(record.MessageJSON), &message); err != nil { + return state, fmt.Errorf("decode Agent history: %w", err) + } + if record.Sequence != len(state.TextHistory)+1 { + return state, errors.New("Agent history sequence is incomplete") + } + state.TextHistory = append(state.TextHistory, message) + default: + return state, errors.New("Agent message kind is unsupported") + } + } + } + if err := validateCloudAgentRuntime(run, &state); err != nil { + return state, err + } + return state, nil +} + +// cloudAgentDecodeForExecution is the only decoder for paths that may resume +// model/tool execution. Historical reads intentionally use cloudAgentDecode: +// a completed turn keeps its frozen policy and capability snapshot and remains +// valid conversation context after the current runtime contract advances. +func cloudAgentDecodeForExecution(run *model.CloudAgentExecution) (cloudAgentRuntime, error) { + state, err := cloudAgentDecode(run) + if err != nil { + return state, WrapAppError(409, "Agent 运行记录无法安全恢复;请新建一轮消息", err) + } + if err := validateCloudAgentPolicySnapshot(state.Policy); err != nil { + return state, WrapAppError(409, "Agent 运行使用旧版执行合同,无法继续原运行;请新建一轮消息", err) + } + return state, nil +} + +func validateCloudAgentRuntime(run *model.CloudAgentExecution, state *cloudAgentRuntime) error { + if run == nil || state == nil { + return errors.New("Agent runtime state is missing") + } + if run.ID == "" { + // Package-level tool tests use an in-memory runtime without a durable + // execution identity. Durable rows are always validated below. + return nil + } + if state.Request.CanvasID == "" || state.Request.Prompt == "" || state.Request.PermissionMode == "" { + return errors.New("Agent runtime request is incomplete") + } + if err := validateCloudAgentRequest(&state.Request); err != nil { + return fmt.Errorf("invalid Agent runtime request: %w", err) + } + if err := validateCloudAgentPolicySnapshotStructure(state.Policy); err != nil { + return err + } + if err := validateCloudAgentProfileSnapshot(state.Profile, state.Policy); err != nil { + return err + } + for scope, read := range state.ProfileReads { + found := false + for _, layer := range state.Profile.Layers { + found = found || layer.Scope == scope + } + if !read || !found { + return errors.New("Agent runtime profile read history is invalid") + } + } + if state.Step < 0 || state.Generations < 0 || state.VideoSeconds < 0 { + return errors.New("Agent runtime budget or step is invalid") + } + if state.ConfirmationRounds < 0 || state.ConfirmationRounds > cloudAgentMaxConfirmationRounds { + return errors.New("Agent runtime confirmation round is invalid") + } + if len(state.ConfirmationFingerprints) > cloudAgentMaxConfirmationRounds { + return errors.New("Agent runtime confirmation fingerprint history is invalid") + } + seenConfirmationPoints := make(map[string]struct{}, len(state.ConfirmationFingerprints)) + for _, fingerprint := range state.ConfirmationFingerprints { + if !cloudAgentSHA256(fingerprint) { + return errors.New("Agent runtime confirmation fingerprint is invalid") + } + if _, exists := seenConfirmationPoints[fingerprint]; exists { + return errors.New("Agent runtime confirmation fingerprint history contains duplicates") + } + seenConfirmationPoints[fingerprint] = struct{}{} + } + if state.PendingConfirmationFingerprint != "" { + if !cloudAgentSHA256(state.PendingConfirmationFingerprint) { + return errors.New("Agent runtime pending confirmation fingerprint is invalid") + } + if _, exists := seenConfirmationPoints[state.PendingConfirmationFingerprint]; !exists { + return errors.New("Agent runtime pending confirmation fingerprint is not recorded") + } + } + if state.ImageInspectCalls < 0 { + return errors.New("Agent runtime image inspection budget is invalid") + } + if state.ReadToolCalls < 0 { + return errors.New("Agent runtime read tool budget is invalid") + } + for nodeID, count := range state.ImageInspectCounts { + if strings.TrimSpace(nodeID) == "" || count < 0 { + return errors.New("Agent runtime image inspection counts are invalid") + } + } + for key, count := range state.ImageInspectionReads { + if strings.TrimSpace(key) == "" || count < 0 { + return errors.New("Agent runtime image inspection read history is invalid") + } + } + for key, count := range state.ToolReadReplays { + if strings.TrimSpace(key) == "" || count < 0 { + return errors.New("Agent runtime read replay history is invalid") + } + } + if (state.Request.Budget.MaxGenerationTasks > 0 && state.Generations > state.Request.Budget.MaxGenerationTasks) || (state.Request.Budget.MaxVideoSeconds > 0 && state.VideoSeconds > state.Request.Budget.MaxVideoSeconds) { + return errors.New("Agent runtime generation budget is invalid") + } + if state.CallIndex < 0 || state.CallIndex > len(state.Calls) || len(state.Calls) > cloudAgentMaxToolCalls { + return errors.New("Agent runtime call cursor is invalid") + } + for toolName, repair := range state.ToolRepairs { + if toolName == "" || utf8.RuneCountInString(toolName) > 80 || repair.Attempt < 1 || repair.Attempt > cloudAgentToolAttemptLimit || repair.GroupID == "" { + return errors.New("Agent runtime tool repair state is invalid") + } + if err := validateCloudAgentID(repair.GroupID, "工具纠错组 ID", 240); err != nil { + return err + } + } + if state.ActiveTaskID != "" && state.MediaTaskID != "" { + return errors.New("Agent runtime has multiple active tasks") + } + if state.HistoryIncludesCurrent && len(state.Canonical.Messages) < len(state.TextHistory) { + return errors.New("Agent compacted history exceeds canonical transcript") + } + if len(state.TaskIDs) == 0 { + return errors.New("Agent runtime task history is invalid") + } + seenTasks := make(map[string]struct{}, len(state.TaskIDs)) + for _, taskID := range state.TaskIDs { + if err := validateCloudAgentID(taskID, "任务 ID", 80); err != nil { + return err + } + if _, exists := seenTasks[taskID]; exists { + return errors.New("Agent runtime task history contains duplicates") + } + seenTasks[taskID] = struct{}{} + } + if state.ActiveTaskID != "" && !cloudAgentContainsString(state.TaskIDs, state.ActiveTaskID) { + return errors.New("Agent runtime active task is not in task history") + } + if state.MediaTaskID != "" { + if !cloudAgentContainsString(state.TaskIDs, state.MediaTaskID) || state.CallIndex >= len(state.Calls) || (state.Calls[state.CallIndex].Function.Name != "generate_media" && state.Calls[state.CallIndex].Function.Name != "image_layer_split") { + return errors.New("Agent runtime media task is not attached to current call") + } + } + if state.AutoPreparedMedia != nil { + if state.AutoPreparedCallHash == "" || state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { + return errors.New("Agent runtime auto media preparation is invalid") + } + current := state.Calls[state.CallIndex] + if current.Function.Name != "generate_media" && current.Function.Name != "image_layer_split" { + return errors.New("Agent runtime auto media preparation is not attached to a media call") + } + if state.AutoPreparedCallHash != cloudAgentApprovalCallHash(current) { + return errors.New("Agent runtime auto media preparation does not match current call") + } + } + if state.Decisions == nil || state.Events == nil { + return errors.New("Agent runtime maps are missing") + } + if state.EventSeqBase < 0 { + return errors.New("Agent runtime event watermark is invalid") + } + // 内存里只有"最近一窗 + 本次转移新产生的部分",超过健全上限说明窗口没有按 + // CloudAgentJournalWindow 载入(或序号基准算错),此时不能继续推进。 + if len(state.Events) > cloudAgentEventWindowSanityLimit { + return errors.New("Agent runtime event window is too large") + } + for index, event := range state.Events { + if event.RunID != run.ID || event.Seq != state.EventSeqBase+index+1 || event.EventID == "" || event.Type == "" || event.Payload == nil || event.CreatedAt.IsZero() { + return errors.New("Agent runtime event history is invalid") + } + if err := validateCloudAgentID(event.EventID, "事件 ID", 240); err != nil { + return err + } + raw, err := json.Marshal(event.Payload) + if err != nil || len(raw) > 128<<10 { + return errors.New("Agent runtime event payload is too large") + } + } + for _, call := range state.Calls { + if err := validateCloudAgentID(call.ID, "工具调用 ID", 160); err != nil || call.Function.Name == "" || utf8.RuneCountInString(call.Function.Name) > 80 || !utf8.ValidString(call.Function.Name) { + return errors.New("Agent runtime tool call is invalid") + } + if len(call.Function.Arguments) > 32000 { + return errors.New("Agent runtime tool arguments are too large") + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &map[string]any{}); err != nil { + return errors.New("Agent runtime tool arguments are invalid") + } + } + if state.Approval != nil { + if err := validateCloudAgentID(state.Approval.ID, "审批 ID", 200); err != nil || state.CallIndex >= len(state.Calls) { + return errors.New("Agent runtime approval is invalid") + } + current := state.Calls[state.CallIndex] + if state.Approval.Call.ID != current.ID || state.Approval.Call.Function.Name != current.Function.Name || state.Approval.Call.Function.Arguments != current.Function.Arguments { + return errors.New("Agent runtime approval does not match current call") + } + if state.Approval.Decision != "" && state.Approval.Decision != "approve" && state.Approval.Decision != "reject" { + return errors.New("Agent runtime approval decision is invalid") + } + } + if state.CallIndex == len(state.Calls) && state.Approval != nil { + return errors.New("Agent runtime has approval without a pending call") + } + return nil +} + +func validateCloudAgentPolicySnapshot(snapshot cloudAgentPolicySnapshot) error { + if err := validateCloudAgentPolicySnapshotStructure(snapshot); err != nil { + return err + } + if snapshot.CompilerVersion != cloudAgentCompilerVersion { + return errors.New("Agent runtime policy compiler is unsupported") + } + if snapshot.CapabilitySetVersion != cloudAgentCapabilitySetVersion { + return errors.New("Agent runtime capability contract is unsupported") + } + if snapshot.ReasoningMode != "off" && snapshot.ReasoningMode != "auto" && snapshot.ReasoningMode != "deep" { + return errors.New("Agent runtime reasoning mode is invalid") + } + system, media, err := prompts.LoadAgentPolicies() + if err != nil { + return fmt.Errorf("load Agent runtime policies: %w", err) + } + if snapshot.SystemPolicyID != system.ID || snapshot.SystemPolicyVersion != system.Version || snapshot.MediaPolicyID != media.ID || snapshot.MediaPolicyVersion != media.Version { + return errors.New("Agent runtime policy version is unsupported") + } + // An existing run keeps its admitted prompt and tool schema. Never resume it + // against changed policies or capabilities under an unchanged version label. + if snapshot.SystemPolicyHash != system.Hash || snapshot.MediaPolicyHash != media.Hash || snapshot.CapabilitySetHash != cloudAgentCapabilitySetHash() { + return errors.New("Agent runtime policy or capability contract has changed") + } + return nil +} + +// validateCloudAgentPolicySnapshotStructure validates the frozen historical +// record without treating the current binary as its authorization source. +// Version/hash equality with the current runtime is checked separately, only +// when an old run is about to execute again. +func validateCloudAgentPolicySnapshotStructure(snapshot cloudAgentPolicySnapshot) error { + for _, field := range []struct { + name string + value string + }{ + {"policy compiler", snapshot.CompilerVersion}, + {"system policy ID", snapshot.SystemPolicyID}, + {"media policy ID", snapshot.MediaPolicyID}, + {"capability set version", snapshot.CapabilitySetVersion}, + } { + if strings.TrimSpace(field.value) == "" || !utf8.ValidString(field.value) || utf8.RuneCountInString(field.value) > 120 { + return fmt.Errorf("Agent runtime %s is invalid", field.name) + } + } + if snapshot.SystemPolicyVersion <= 0 || snapshot.MediaPolicyVersion <= 0 { + return errors.New("Agent runtime policy version is invalid") + } + if snapshot.ReasoningMode != "off" && snapshot.ReasoningMode != "auto" && snapshot.ReasoningMode != "deep" { + return errors.New("Agent runtime reasoning mode is invalid") + } + for _, field := range []struct { + name string + value string + }{ + {"system policy hash", snapshot.SystemPolicyHash}, + {"media policy hash", snapshot.MediaPolicyHash}, + {"capability set hash", snapshot.CapabilitySetHash}, + {"profile revision", snapshot.ProfileRevision}, + {"profile hash", snapshot.ProfileHash}, + } { + if !cloudAgentSHA256(field.value) { + return fmt.Errorf("Agent runtime %s is invalid", field.name) + } + } + return nil +} + +func cloudAgentSHA256(value string) bool { + if len(value) != 64 { + return false + } + for _, r := range value { + if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) { + return false + } + } + return true +} + +func cloudAgentContainsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +const cloudAgentStateJSONLimit = 512 << 10 + +func cloudAgentCheckpointJSON(state *cloudAgentRuntime) ([]byte, error) { + checkpoint := *state + checkpoint.Canonical.Messages = nil + checkpoint.TextHistory = nil + checkpoint.Events = nil + return json.Marshal(checkpoint) +} + +// cloudAgentPruneReadCacheForCheckpoint is a last-resort durability guard. +// ToolReadResults is a replay cache: losing one entry means a later read may +// execute again, but allowing that cache to make the whole runtime impossible +// to checkpoint loses the run and prevents context compaction from starting. +// Remove the largest entries first so a single oversized canvas/skill result +// cannot strand the run above the 512 KiB state limit. +func cloudAgentPruneReadCacheForCheckpoint(state *cloudAgentRuntime, limit int) (removed int) { + if state == nil || len(state.ToolReadResults) == 0 || limit <= 0 { + return 0 + } + raw, err := cloudAgentCheckpointJSON(state) + if err != nil || len(raw) <= limit { + return 0 + } + type candidate struct { + key string + bytes int + inContext bool + error bool + } + candidates := make([]candidate, 0, len(state.ToolReadResults)) + for key, cached := range state.ToolReadResults { + entryBytes, _ := json.Marshal(cached) + candidates = append(candidates, candidate{ + key: key, + bytes: len(entryBytes), + inContext: cloudAgentReadResultInContext(state, cached.Result), + error: cached.Error != "", + }) + } + // Prefer dropping errors and entries already absent from the current + // transcript. Keep results currently visible to the model for as long as + // possible because compaction may need them for a replay after eviction. + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].inContext != candidates[j].inContext { + return !candidates[i].inContext + } + if candidates[i].error != candidates[j].error { + return candidates[i].error + } + if candidates[i].bytes != candidates[j].bytes { + return candidates[i].bytes > candidates[j].bytes + } + return candidates[i].key < candidates[j].key + }) + for _, item := range candidates { + if raw, err = cloudAgentCheckpointJSON(state); err != nil || len(raw) <= limit { + break + } + delete(state.ToolReadResults, item.key) + delete(state.ToolReadReplays, item.key) + removed++ + } + if len(state.ToolReadResults) == 0 { + state.ToolReadResults = nil + } + if len(state.ToolReadReplays) == 0 { + state.ToolReadReplays = nil + } + return removed +} + +func cloudAgentSave(run *model.CloudAgentExecution, state *cloudAgentRuntime) error { + if run == nil || state == nil { + return errors.New("Agent runtime state is missing") + } + // 轮内唯一裁剪 = 图片:超出保留轮次的看图结果换成文字回执(正文一律保留)。 + // 上游每一步都会重新读取历史里的图片并按视觉 token 计费,保留整段历史既贵又没有新信息。 + if changed, pruned := cloudAgentPruneInspectedImages(&state.Canonical, nil); changed && run.ID != "" { + state.event(run.ID, "context_images_pruned", map[string]any{ + "prunedImages": pruned, "retentionRounds": cloudAgentImageRetentionRounds, + "text": "已把超出保留轮次的看图结果移出模型上下文(保留文字回执与 nodeId)", + }) + } + if run.ID != "" { + if err := validateCloudAgentRuntime(run, state); err != nil { + return cloudAgentCheckpointFailure("runtime validation", err) + } + for index, event := range state.Events { + sequence := state.EventSeqBase + index + 1 + if event.Seq != sequence || event.RunID != run.ID || event.EventID != fmt.Sprintf("%s:%d", run.ID, sequence) { + return cloudAgentCheckpointFailure("event identity", errors.New("Agent event sequence or identity is invalid")) + } + } + } + // The canonical transcript and event journal are persisted separately. The + // bounded StateJSON therefore contains only runtime metadata and replay + // caches, and the latter must never be allowed to block a checkpoint. + raw, err := cloudAgentCheckpointJSON(state) + if err != nil { + return cloudAgentCheckpointFailure("state encode", err) + } + if len(raw) > cloudAgentStateJSONLimit { + cloudAgentPruneReadCacheForCheckpoint(state, cloudAgentStateJSONLimit) + raw, err = cloudAgentCheckpointJSON(state) + if err != nil { + return cloudAgentCheckpointFailure("state encode after cache pruning", err) + } + } + if len(raw) > cloudAgentStateJSONLimit { + return cloudAgentCheckpointFailure("state size", fmt.Errorf("Agent 状态超过 512KB 上限(%d bytes)", len(raw))) + } + run.CanvasID, run.ActiveTaskID, run.MediaTaskID = state.Request.CanvasID, state.ActiveTaskID, state.MediaTaskID + run.ParentID = state.ParentID + if run.Title == "" { + run.Title = truncateRunes(state.Request.Prompt, 80) + } + // 事件按"窗口 + 水位"落库:run.Journal 只是内存窗口的映射(窗口之外的历史仍在 + // 事件表里),写入侧(repository.MutateCloudAgent)只追加 seq > 旧水位的行, + // 因此窗口左移不会丢历史。 + run.Journal = make([]model.CloudAgentEventRecord, 0, len(state.Events)) + for _, event := range state.Events { + body, err := json.Marshal(event) + if err != nil { + return cloudAgentCheckpointFailure("event encode", err) + } + run.Journal = append(run.Journal, model.CloudAgentEventRecord{RunID: run.ID, UserID: run.UserID, Sequence: event.Seq, EventJSON: string(body), CreatedAt: event.CreatedAt}) + } + run.Transcript = make([]model.CloudAgentMessageRecord, 0, len(state.Canonical.Messages)+len(state.TextHistory)) + for index, message := range state.Canonical.Messages { + body, err := json.Marshal(message) + if err != nil { + return cloudAgentCheckpointFailure("canonical message encode", err) + } + run.Transcript = append(run.Transcript, model.CloudAgentMessageRecord{RunID: run.ID, UserID: run.UserID, Kind: "canonical", Sequence: index + 1, MessageJSON: string(body)}) + } + for index, message := range state.TextHistory { + body, err := json.Marshal(message) + if err != nil { + return cloudAgentCheckpointFailure("history encode", err) + } + run.Transcript = append(run.Transcript, model.CloudAgentMessageRecord{RunID: run.ID, UserID: run.UserID, Kind: "history", Sequence: index + 1, MessageJSON: string(body)}) + } + // 水位 = 窗口之前已入库的条数 + 本次载入/新增的窗口条数,必须与事件表里的 + // 最大 seq 一致,否则下一次载入的窗口与水位会对不上。 + run.CheckpointVersion, run.EventCount, run.MessageCount = 2, state.EventSeqBase+len(state.Events), len(run.Transcript) + run.StateJSON = string(raw) + return nil +} diff --git a/backend/internal/app/cloud_agent_runtime_tools.go b/backend/internal/app/cloud_agent_runtime_tools.go new file mode 100644 index 000000000..438c424c5 --- /dev/null +++ b/backend/internal/app/cloud_agent_runtime_tools.go @@ -0,0 +1,622 @@ +// 画布 Agent 工具调用的执行与结果回填。 +// +// 只读工具可以在同一步内批量执行(advanceCloudAgentReadBatch),结果按参数哈希缓存; +// 写工具逐个执行,执行前后都会让相关读缓存失效,避免模型基于过期画布做决定。 + +package app + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func cloudAgentToolResult(runID string, state *cloudAgentRuntime, call cloudAgentCall, result any, err error) bool { + payload := map[string]any{"toolName": call.Function.Name, "callId": call.ID, "arguments": call.Function.Arguments} + if call.Function.Name == "skill_read_file" { + var args struct { + SkillID string `json:"skillId"` + Path string `json:"path"` + } + if json.Unmarshal([]byte(call.Function.Arguments), &args) == nil { + payload["skillId"], payload["path"] = args.SkillID, args.Path + for _, skill := range state.Skills { + if skill.ID == args.SkillID { + payload["skillName"] = skill.Name + break + } + } + } + } + kind := "tool_completed" + if err != nil { + detail, ok := result.(map[string]any) + if !ok || detail == nil { + detail = map[string]any{} + } + // 模型漏了必填字段(或参数不是对象)时,即便业务校验抛的是普通 AppError,也按 + // "参数契约错误"处理:把本轮实际暴露的 schema 回给模型,它才改得对。 + // 只在业务侧确实按参数问题拒绝(400/422)时套用:上游 5xx 或网络错误即便同时漏字段, + // 也该算上游故障,别把锅扣到参数上。已分类的参数错误不再套壳,避免覆盖更精确的反馈。 + var appErr *AppError + var existingArgumentErr *cloudAgentArgumentError + if missing := cloudAgentMissingRequiredArguments(state.Canonical.Tools, call); len(missing) > 0 && + !errors.As(err, &existingArgumentErr) && + errors.As(err, &appErr) && appErr != nil && (appErr.Status == 400 || appErr.Status == 422) { + err = &cloudAgentFieldArgumentError{ + error: &cloudAgentArgumentError{err}, + Field: strings.Join(missing, ","), Issue: "required", + } + } + message := cloudAgentSafeToolError(err) + detail["error"] = message + var admissionErr *cloudAgentMediaAdmissionError + if errors.As(err, &admissionErr) { + detail["reason"], detail["nodeId"] = admissionErr.Reason, admissionErr.NodeID + } + var argumentErr *cloudAgentArgumentError + if errors.As(err, &argumentErr) { + detail["reason"] = "invalid_tool_arguments" + var fieldErr *cloudAgentFieldArgumentError + if errors.As(err, &fieldErr) { + detail["field"], detail["issue"] = fieldErr.Field, fieldErr.Issue + } + // Use this run's advertised contract, including its permission scope. + for _, tool := range state.Canonical.Tools { + function, _ := tool["function"].(map[string]any) + if function["name"] == call.Function.Name { + detail["parameters"] = function["parameters"] + break + } + } + detail["guidance"] = "本次调用未执行,请按 parameters 修正参数后重试,不要重复提交相同的错误参数" + if call.Function.Name == "canvas_get_state" { + detail["exampleArguments"] = map[string]any{} + } + if call.Function.Name == "canvas_apply_ops" { + detail["exampleArguments"] = map[string]any{"snapshotHash": "", "ops": []any{map[string]any{"type": "add_node", "id": "", "nodeType": "text", "content": ""}}} + } + } + // 稳定归类 + 可行动字段:只加标注,不改任何放行/拒绝判定。 + // allowed 与执行器用的是同一份判定(cloudAgentToolAllowed 是纯函数,结果一致)。 + if class, retryable, requiredAction := cloudAgentToolErrorClass(state.Request, call, err, cloudAgentToolAllowed(state.Request, call.Function.Name)); class != "" { + detail["errorClass"], detail["errorClassLabel"] = class, cloudAgentToolErrorLabel(class) + detail["retryable"] = retryable + if requiredAction != "" { + detail["requiredAction"] = requiredAction + } + payload["errorClass"] = class + } + result = detail + kind = "tool_failed" + payload["text"] = message + } else { + payload["text"] = "工具执行成功" + } + if inspection, ok := result.(cloudAgentImageInspection); ok && err == nil { + receipt, _ := json.Marshal(inspection.Receipt) + payload["result"] = inspection.Receipt + state.event(runID, kind, payload) + // tool 角色只接受字符串内容(四种上游图式都是纯文本),因此工具回执照常入历史, + // 图片另起一条 user 消息携带,并显式标注为数据而非指令。 + state.Canonical.Messages = append(state.Canonical.Messages, + map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(receipt)}) + // 重复查看时只回执文字(ImageURL 为空),不再附图。 + if strings.TrimSpace(inspection.ImageURL) != "" { + cloudAgentStageImageInspection(state, inspection) + } + // 一批里可能有多个调用(模型一次发起 parallel tool calls),上游要求 + // assistant(tool_calls) 之后紧跟每一个 tool_call_id 的 tool 消息,所以图片 + // 不能插在 tool 结果之间。只有本批最后一个调用执行完,才把整批缓冲合并成 + // 一条 user 消息追加在全部 tool 结果之后。 + if state.CallIndex+1 >= len(state.Calls) { + cloudAgentFlushPendingImages(state) + } + state.CallIndex++ + state.Approval = nil + // 看图是只读成功路径,不占自动纠错名额(那名额只给写/生成类工具的预执行参数错误)。 + return false + } + exhausted := cloudAgentTrackToolRepair(runID, state, call, result, err, payload) + raw, _ := json.Marshal(result) + payload["result"] = result + if call.Function.Name == "skill_read_file" && err == nil { + // SSE/UI needs the read receipt, not another durable copy of skill text. + if fields, ok := result.(map[string]any); ok { + receipt := make(map[string]any, len(fields)) + for key, value := range fields { + if key != "content" { + receipt[key] = value + } + } + payload["result"] = receipt + } + } + state.event(runID, kind, payload) + state.Canonical.Messages = append(state.Canonical.Messages, map[string]any{"role": "tool", "tool_call_id": call.ID, "content": string(raw)}) + state.CallIndex++ + state.Approval = nil + return exhausted +} + +// cloudAgentReadResultInContext reports whether the full cached result is still +// present in the canonical transcript. Context compaction deliberately removes old +// tool bodies, so a replay must restore the body once instead of returning a receipt +// that refers to history the model can no longer see. +func cloudAgentReadResultInContext(state *cloudAgentRuntime, result json.RawMessage) bool { + if state == nil || len(result) == 0 { + return false + } + // Unit-level callers may exercise the cache helper without constructing a + // transcript. The real runtime always has the first tool message here. + if len(state.Canonical.Messages) == 0 { + return true + } + want := string(result) + for _, message := range state.Canonical.Messages { + if stringValue(message["role"]) == "tool" && stringValue(message["content"]) == want { + return true + } + } + return false +} + +// cloudAgentBatchableReadTool identifies read calls that are independent of +// each other and safe to execute from one model tool-call response. Keeping +// this list limited to the cacheable read contract is important: mutations, +// approvals, user questions, media and vision calls remain ordered state +// transitions and must each retain their existing semantics. +func cloudAgentBatchableReadTool(name string) bool { + return cloudAgentReadToolReadOnly(name) +} + +func cloudAgentInvalidateReadCache(state *cloudAgentRuntime) { + if state == nil { + return + } + // Canvas writes invalidate only canvas projections. Skill documents, + // profile preferences, and the model catalog do not change when a canvas is + // edited, so retaining them avoids re-reading large unrelated tool results. + for key := range state.ToolReadResults { + if strings.HasPrefix(key, "canvas_get_state:") || strings.HasPrefix(key, "canvas_read_storyboard:") { + delete(state.ToolReadResults, key) + delete(state.ToolReadReplays, key) + } + } +} + +// advanceCloudAgentReadBatch executes consecutive independent reads from the +// same model response before returning to the scheduler. The previous path +// checkpointed after every read, which turned one parallel tool-call response +// into N scheduler/database transitions. Results still get one tool message +// per call, in the original order, so every provider contract remains valid. +func (s *Service) advanceCloudAgentReadBatch(run *model.CloudAgentExecution, state *cloudAgentRuntime) (bool, error) { + if run == nil || state == nil || (run.Status != "running" && run.Status != "queued") { + return true, nil + } + if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { + return false, nil + } + type outcome struct { + call cloudAgentCall + result any + err error + } + outcomes := make([]outcome, 0, len(state.Calls)-state.CallIndex) + // Reads are collected before one checkpoint write. During collection, their + // successful receipts are not in Canonical.Messages yet, so the regular cache + // layer cannot see that an earlier call in this same batch already returned + // the full body. Track those keys here to avoid appending the same large result + // repeatedly when a model emits duplicate parallel reads. + batchReadResults := make(map[string]bool, len(state.Calls)-state.CallIndex) + for index := state.CallIndex; index < len(state.Calls); index++ { + call := state.Calls[index] + if !cloudAgentBatchableReadTool(call.Function.Name) || !cloudAgentToolAllowed(state.Request, call.Function.Name) { + break + } + call = s.cloudAgentRefreshStepSnapshotHash(run, state, call) + state.Calls[index] = call + if call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" { + state.RuntimeRunID = run.ID + } + result, err := cloudAgentReadToolCached(s.repo, run.UserID, state, call, s) + if err == nil { + cacheKey := cloudAgentReadCacheKeyForState(s.repo, run.UserID, state, call) + if batchReadResults[cacheKey] { + // The first receipt will be appended before this one in the same + // checkpoint transaction, so this acknowledgement is truthful even + // though the canonical transcript has not been updated yet. + result = map[string]any{ + "cacheReplay": true, + "replayCount": state.ToolReadReplays[cacheKey], + "message": "该只读结果已在本批工具调用的前序结果中,请直接使用已有结果,不要再次读取", + } + } else { + batchReadResults[cacheKey] = true + } + } + outcomes = append(outcomes, outcome{call: call, result: result, err: err}) + var loopErr *cloudAgentReadLoopError + if errors.As(err, &loopErr) { + break + } + } + if len(outcomes) == 0 { + return false, nil + } + + s.storageMu.Lock() + defer s.storageMu.Unlock() + err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + for _, item := range outcomes { + var readLoopErr *cloudAgentReadLoopError + if errors.As(item.err, &readLoopErr) { + // A provider tool-call turn is atomic from the transcript's point of + // view: every declared tool_call_id needs a tool message, even when a + // read guard terminates the run. Record the triggering error and then + // explicit skipped receipts before appending run_failed, otherwise a + // later resume/replay produces an invalid provider transcript. + cloudAgentToolResult(current.ID, state, item.call, item.result, item.err) + for state.CallIndex < len(state.Calls) { + pending := state.Calls[state.CallIndex] + cloudAgentToolResult(current.ID, state, pending, + map[string]any{"skipped": true, "reason": readLoopErr.reasonCode()}, + nil) + } + current.Status = "failed" + current.FailureMessage = truncateRunes(readLoopErr.Error(), 1000) + cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(current.FailureMessage, 120), state) + reason := readLoopErr.reasonCode() + state.event(run.ID, "run_failed", map[string]any{ + "text": current.FailureMessage, "reason": reason, + "toolName": item.call.Function.Name, "readCount": readLoopErr.Count, + }) + break + } + cloudAgentRecordToolResult(current, state, item.call, item.result, item.err) + } + return cloudAgentSave(current, state) + }) + return true, err +} + +func (s *Service) advanceCloudAgentTool(run *model.CloudAgentExecution, state *cloudAgentRuntime) error { + if run == nil || state == nil || (run.Status != "running" && run.Status != "queued") { + return nil + } + if state.CallIndex < 0 || state.CallIndex >= len(state.Calls) { + return s.failCloudAgent(run, state, "Agent 工具调用状态无效,本轮已停止") + } + call := state.Calls[state.CallIndex] + call = s.cloudAgentRefreshStepSnapshotHash(run, state, call) + state.Calls[state.CallIndex] = call + if state.Approval != nil && state.Approval.Decision == "" { + return nil + } + if state.Approval != nil && state.Approval.Decision != "" && state.Approval.CallHash != "" && state.Approval.CallHash != cloudAgentApprovalCallHash(call) { + return s.terminateCloudAgent(run, "审批内容与待执行操作不一致,本轮已停止") + } + allowed := cloudAgentToolAllowed(state.Request, call.Function.Name) + mediaTool := call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" + // 媒体工具在所有模式下都进入审批;其他写入只在 request_approval 下审批。 + if allowed && cloudAgentWrite(call.Function.Name) && (state.Request.PermissionMode == "request_approval" || mediaTool) && state.Approval == nil { + var plan *cloudAgentMediaPlan + var modelName string + var mediaRequest CreateTaskRequest + var preparedTask *model.Task + var mediaPreparation *creationTaskPreparation + policy, err := s.RuntimePolicy() + if err != nil { + return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止") + } + if call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split" { + mediaCall := cloudAgentMediaCall(call) + req, prepared, err := s.prepareCloudAgentMedia(run, state, mediaCall) + if err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + mediaRequest, plan = req, prepared + // A durable auto checkpoint already contains the exact admitted input and + // quote. Reusing it avoids a second dry admission after a worker restart. + if plan.Prepared == nil { + // Dry admission validates the selected model and prompt limits without a task or charge. + mediaPreparation = &creationTaskPreparation{} + req.creationPrepare = mediaPreparation + preparedTask, err = s.CreateTask(run.UserID, req) + if err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + // Resolve model-owned defaults once during the dry admission. Both the + // eventual task and the canvas draft must use this exact resolved input; + // otherwise auto mode reports a false tool failure for omitted size or + // duration and makes the model spend another turn repairing its own call. + if err := applyCloudAgentResolvedMediaDefaults(&req, prepared, preparedTask); err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + mediaRequest, plan = req, prepared + } + modelName, err = s.cloudAgentMediaModelName(plan.Args) + if err != nil { + return s.cloudAgentMediaError(run, state, "admission", false, false, err) + } + } + if plan != nil && state.Request.PermissionMode == "auto" && !mediaTool { + if plan.Prepared != nil { + // The draft and quote were already checkpointed. Reuse them after a + // worker restart instead of dry-admitting and mutating the canvas again. + latest, err := s.repo.CloudAgent(run.UserID, run.ID) + if err != nil { + return err + } + fresh, err := cloudAgentDecode(latest) + if err != nil { + return err + } + return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan) + } + // Auto media is a direct, server-admitted write. Prepare the draft and + // its immutable quote in one checkpoint transaction, then release the + // lock before enqueueCloudAgentTask performs the billed submission. + s.storageMu.Lock() + err := s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { + return err + } + canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) + if err != nil { + return err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return err + } + plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc) + raw, err := json.Marshal(plan.Args) + if err != nil { + return err + } + call.Function.Arguments = string(raw) + state.Calls[state.CallIndex] = call + preparedMedia, err := prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order) + if err != nil { + return err + } + plan.Prepared = preparedMedia + state.AutoPreparedMedia = preparedMedia + state.AutoPreparedCallHash = cloudAgentApprovalCallHash(call) + return cloudAgentSave(current, state) + }) + s.storageMu.Unlock() + if err != nil { + latest, readErr := s.repo.CloudAgent(run.UserID, run.ID) + if readErr != nil || latest.Revision != run.Revision { + return err + } + fresh, decodeErr := cloudAgentDecode(latest) + if decodeErr != nil { + return decodeErr + } + return s.cloudAgentMediaError(latest, &fresh, "admission", false, false, err) + } + latest, err := s.repo.CloudAgent(run.UserID, run.ID) + if err != nil { + return err + } + fresh, err := cloudAgentDecode(latest) + if err != nil { + return err + } + return s.enqueueCloudAgentTask(latest, &fresh, mediaRequest, plan) + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + var preview cloudAgentApprovalPreview + var preparedMedia *cloudAgentPreparedMedia + if plan != nil { + if err := createCloudAgentMediaNode(repo, run.UserID, state.Request.CanvasID, plan, nil, policy, cloudAgentCanvasEventRecorder(run.ID, state)); err != nil { + return err + } + canvas, err := repo.CanvasProjectForUser(run.UserID, state.Request.CanvasID) + if err != nil { + return err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return err + } + plan.Args.SnapshotHash = cloudAgentMediaContentHash(doc) + raw, err := json.Marshal(plan.Args) + if err != nil { + return err + } + call.Function.Arguments = string(raw) + state.Calls[state.CallIndex] = call + preview = cloudAgentMediaApprovalPreview(plan, modelName) + preparedMedia, err = prepareCloudAgentMediaApproval(repo, run.UserID, doc, plan, mediaRequest, preparedTask, mediaPreparation.Order) + if err != nil { + return err + } + } else { + var mutationErr error + switch call.Function.Name { + case "canvas_create_storyboard": + storyboardPlan, err := prepareCloudAgentStoryboardCreate(repo, run.UserID, state.Request.CanvasID, call) + mutationErr = err + if err == nil { + preview = storyboardPlan.Preview + } + case "canvas_edit_storyboard": + storyboardPlan, err := prepareCloudAgentStoryboardEdit(repo, run.UserID, state.Request.CanvasID, call) + mutationErr = err + if err == nil { + preview = storyboardPlan.Preview + } + case "canvas_edit_batch_table": + batchPlan, err := prepareCloudAgentBatchTableEdit(repo, run.UserID, state.Request.CanvasID, call) + mutationErr = err + if err == nil { + preview = batchPlan.Preview + } + case "canvas_arrange_nodes": + arrangePlan, err := prepareCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call) + mutationErr = err + if err == nil { + preview = arrangePlan.Preview + } + default: + canvasPlan, err := prepareCloudAgentCanvasMutation(repo, run.UserID, state.Request.CanvasID, call) + mutationErr = err + if err == nil { + preview = canvasPlan.Preview + } + } + if mutationErr != nil { + var argumentErr *cloudAgentArgumentError + if errors.As(mutationErr, &argumentErr) { + cloudAgentRecordToolResult(current, state, call, nil, mutationErr) + return cloudAgentSave(current, state) + } + var appErr *AppError + if errors.As(mutationErr, &appErr) && appErr != nil { + return failCloudAgentAdmission(current, state, run.ID, mutationErr) + } + return mutationErr + } + } + state.Approval = &cloudAgentApproval{ID: fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex), Call: call, CallHash: cloudAgentApprovalCallHash(call), Preview: preview, ModelName: modelName, Prepared: preparedMedia} + if preparedMedia != nil { + if err := pinCloudAgentPreparedMedia(repo, run.UserID, run.ID, state.Approval.ID, preparedMedia); err != nil { + return err + } + } + current.Status = "waiting_approval" + state.event(run.ID, "approval_requested", map[string]any{"approvalId": state.Approval.ID, "toolName": call.Function.Name, "modelName": modelName, "arguments": json.RawMessage(call.Function.Arguments), "preview": preview, "prepared": preparedMedia.publicView(), "text": preview.Description}) + return cloudAgentSave(current, state) + }) + } + if allowed && call.Function.Name == "plan_update" && cloudAgentPlanRequiresFirstApproval(state, call) { + if preview, ok := cloudAgentPlanApprovalPreview(call); ok { + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, _ *repository.Repository) error { + approvalID := fmt.Sprintf("%s-%d-%d", run.ID, state.Step, state.CallIndex) + state.Approval = &cloudAgentApproval{ID: approvalID, Call: call, CallHash: cloudAgentApprovalCallHash(call), Preview: preview} + current.Status = "waiting_approval" + state.event(run.ID, "approval_requested", map[string]any{"approvalId": approvalID, "toolName": call.Function.Name, "arguments": json.RawMessage(call.Function.Arguments), "preview": preview, "text": preview.Description}) + return cloudAgentSave(current, state) + }) + } + } + if allowed && (call.Function.Name == "generate_media" || call.Function.Name == "image_layer_split") && state.Approval != nil && state.Approval.Decision == "approve" { + return s.advanceCloudAgentMedia(run, state, cloudAgentMediaCall(call)) + } + policy, err := s.RuntimePolicy() + if err != nil { + return s.terminateCloudAgent(run, "Agent 运行策略不可用,本轮已停止") + } + // 看图的资源与能力校验在写事务外完成;真实图片只在模型任务执行时读取。 + var inspectionResult any + var inspectionErr error + if allowed && call.Function.Name == "canvas_inspect_image" && state.Request.VisionEnabled { + inspectionResult, inspectionErr = s.prepareCloudAgentImageInspection(run.UserID, state.Request.CanvasID, state, call) + if errors.Is(inspectionErr, errCloudAgentImageInspectionBudget) { + return s.failCloudAgent(run, state, cloudAgentImageInspectionBudgetMessage) + } + } + // Skill reads use the domain repository and filesystem, not the checkpoint + // transaction's connection. Read first to avoid nesting DB reads on SQLite. + var skillResult any + var skillErr error + if allowed && (call.Function.Name == "skill_read_file" || call.Function.Name == "model_list" || call.Function.Name == "image_annotation_render") { + state.RuntimeRunID = run.ID + if call.Function.Name == "image_annotation_render" { + skillResult, skillErr = cloudAgentReadTool(s.repo, run.UserID, state, call, s) + } else { + // 技能文件和模型目录都是稳定的只读结果。统一走检查点缓存, + // 使重复调用不会再次访问文件系统/数据库,也不会把大结果重复 + // 写入后续模型上下文。 + skillResult, skillErr = cloudAgentReadToolCached(s.repo, run.UserID, state, call, s) + } + } + s.storageMu.Lock() + defer s.storageMu.Unlock() + return s.repo.MutateCloudAgent(run.UserID, run.ID, run.Revision, func(current *model.CloudAgentExecution, repo *repository.Repository) error { + var result any + var toolErr error + switch { + case !allowed: + // 幻觉出来的工具名与"真被权限挡住"要分开反馈:前者模型根本不该发这个调用, + // 后者是授权边界问题。两者仍然一律拒绝,只是给模型的下一步更明确。 + if !cloudAgentPlatformToolNames()[call.Function.Name] { + toolErr = BadAuthRequest("模型调用了不存在的工具「" + truncateRunes(call.Function.Name, 60) + "」,本轮已拒绝;请只使用本轮工具表里列出的工具") + } else { + toolErr = BadAuthRequest("工具未获本轮权限授权") + } + case call.Function.Name == "canvas_apply_ops": + result, toolErr = applyCloudAgentCanvas(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) + case call.Function.Name == "canvas_arrange_nodes": + result, toolErr = applyCloudAgentArrangeNodes(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) + case call.Function.Name == "canvas_create_storyboard", call.Function.Name == "canvas_edit_storyboard": + result, toolErr = applyCloudAgentStoryboardMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) + case call.Function.Name == "canvas_edit_batch_table": + result, toolErr = applyCloudAgentBatchTableMutation(repo, run.UserID, state.Request.CanvasID, call, policy, cloudAgentCanvasEventRecorder(run.ID, state)) + case call.Function.Name == "canvas_inspect_image": + result, toolErr = inspectionResult, inspectionErr + if toolErr == nil && inspectionResult != nil { + if inspection, ok := inspectionResult.(cloudAgentImageInspection); ok { + state.markCanvasImageInspection(stringValue(inspection.Receipt["nodeId"]), strings.TrimSpace(inspection.ImageURL) != "") + if inspection.CacheKey != "" { + if state.ImageInspectionReads == nil { + state.ImageInspectionReads = map[string]int{} + } + state.ImageInspectionReads[inspection.CacheKey]++ + } + } + } + case call.Function.Name == "skill_read_file", call.Function.Name == "model_list", call.Function.Name == "image_annotation_render": + result, toolErr = skillResult, skillErr + default: + result, toolErr = cloudAgentReadToolCached(repo, run.UserID, state, call) + } + if toolErr == nil && cloudAgentWrite(call.Function.Name) { + // A successful canvas mutation changes the read model. Do not replay a + // pre-mutation canvas snapshot later in the same Agent run. + cloudAgentInvalidateReadCache(state) + } + var readLoopErr *cloudAgentReadLoopError + if errors.As(toolErr, &readLoopErr) { + cloudAgentRecordToolResult(current, state, call, result, toolErr) + current.Status = "failed" + current.FailureMessage = truncateRunes(readLoopErr.Error(), 1000) + cloudAgentDropInterjections(run.ID, "本轮已结束:"+truncateRunes(current.FailureMessage, 120), state) + reason := readLoopErr.reasonCode() + state.event(run.ID, "run_failed", map[string]any{ + "text": current.FailureMessage, "reason": reason, + "toolName": call.Function.Name, "readCount": readLoopErr.Count, + }) + return cloudAgentSave(current, state) + } + if call.Function.Name == "plan_update" && toolErr == nil { + state.event(run.ID, "plan_updated", map[string]any{"items": state.Plan, "pendingTitles": cloudAgentPendingPlanItems(state.Plan)}) + } + if call.Function.Name == "ask_user" && toolErr == nil { + payload, _ := result.(map[string]any) + if payload["phase"] == "question" { + state.event(run.ID, "user_question", payload) + cloudAgentRecordToolResult(current, state, call, result, nil) + skipRemainingCloudAgentCalls(run.ID, state) + current.Status = "completed" + return cloudAgentSave(current, state) + } + // The server-side round limit turns further questions into a normal + // tool result so the model must continue with safe defaults. + cloudAgentRecordToolResult(current, state, call, result, nil) + return cloudAgentSave(current, state) + } + cloudAgentRecordToolResult(current, state, call, result, toolErr) + return cloudAgentSave(current, state) + }) +} diff --git a/backend/internal/app/cloud_agent_tool_dispatch_test.go b/backend/internal/app/cloud_agent_tool_dispatch_test.go index e84cd2094..956e33774 100644 --- a/backend/internal/app/cloud_agent_tool_dispatch_test.go +++ b/backend/internal/app/cloud_agent_tool_dispatch_test.go @@ -4,6 +4,7 @@ import ( "go/ast" "go/parser" "go/token" + "io/fs" "sort" "strings" "testing" @@ -114,8 +115,8 @@ func cloudAgentDispatchedToolNames(t *testing.T) []string { file string fn string }{ - {"cloud_agent_runtime.go", "advanceCloudAgentTool"}, - {"cloud_agent_tools.go", "cloudAgentReadTool"}, + {"app 包", "advanceCloudAgentTool"}, + {"app 包", "cloudAgentReadTool"}, } { file := parseCloudAgentFile(t, target.file) found := false @@ -210,13 +211,27 @@ func collectStringLiteralsOf(nodes []ast.Expr, out map[string]bool) { } } +// parseCloudAgentFile 解析 app 包内全部非测试源码并合成一个 *ast.File。 +// +// 守卫按函数名定位分派实现,而不是写死文件名:大文件按职责拆分后 +// (例如 advanceCloudAgentTool 移到 cloud_agent_runtime_tools.go),守卫依然有效。 +// name 仅用于错误信息。 func parseCloudAgentFile(t *testing.T, name string) *ast.File { t.Helper() - file, err := parser.ParseFile(token.NewFileSet(), name, nil, 0) + fset := token.NewFileSet() + packages, err := parser.ParseDir(fset, ".", func(info fs.FileInfo) bool { + return !strings.HasSuffix(info.Name(), "_test.go") + }, 0) if err != nil { - t.Fatalf("解析 %s 失败:%v", name, err) + t.Fatalf("解析 app 包(%s)失败:%v", name, err) } - return file + merged := &ast.File{Name: ast.NewIdent("app")} + for _, pkg := range packages { + for _, file := range pkg.Files { + merged.Decls = append(merged.Decls, file.Decls...) + } + } + return merged } func sortedKeys(values map[string]bool) []string { diff --git a/backend/internal/app/cloud_agent_tools.go b/backend/internal/app/cloud_agent_tools.go index ba977abb7..94755e95c 100644 --- a/backend/internal/app/cloud_agent_tools.go +++ b/backend/internal/app/cloud_agent_tools.go @@ -1,351 +1,12 @@ package app import ( - "bytes" "crypto/sha256" "encoding/json" - "errors" "fmt" - "image" - "image/color" - "image/draw" - "image/png" - "sort" "strings" - "time" - "unicode" - "unicode/utf8" - - "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/repository" ) -type cloudAgentSkill struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description,omitempty"` - Version string `json:"version"` - Hash string `json:"hash"` - Instruction string `json:"instruction,omitempty"` - Files map[string]string `json:"files,omitempty"` -} - -const cloudAgentSkillEntryPath = "SKILL.md" - -func cloudAgentSkillPaths(skill cloudAgentSkill) []string { - paths := make([]string, 0, len(skill.Files)+1) - seen := make(map[string]struct{}, len(skill.Files)+1) - if strings.TrimSpace(skill.Instruction) != "" { - paths = append(paths, cloudAgentSkillEntryPath) - seen[cloudAgentSkillEntryPath] = struct{}{} - } - for path := range skill.Files { - if _, exists := seen[path]; exists { - continue - } - paths = append(paths, path) - seen[path] = struct{}{} - } - sort.Strings(paths) - return paths -} - -// cloudAgentSkillSearch* 实现 Agent 侧的技能检索:与 recall_lessons 的记忆检索同构 -// (同一套分词器与三档加权),数据源为本轮冻结的技能快照——搜到的必然是能读的。 -// 只返回「哪张卡值得读 + 路径」,正文仍走 skill_read_file 的渐进披露,技能内容永不整体内联。 - -const ( - cloudAgentSkillSearchTokenMax = 8 - cloudAgentSkillSearchDefault = 8 - cloudAgentSkillSearchMax = 20 - cloudAgentSkillSnippetRunes = 120 - // 一次检索最多下发多少条卡路径(所有命中条目共享预算),防止大包把上下文撑爆。 - cloudAgentSkillSearchCardBudget = 40 -) - -func cloudAgentSkillSearchTokens(keyword string) []string { - tokens := make([]string, 0, cloudAgentSkillSearchTokenMax) - seen := make(map[string]bool, cloudAgentSkillSearchTokenMax) - for _, raw := range strings.FieldsFunc(keyword, cloudAgentSkillTokenSeparator) { - token := strings.ToLower(strings.TrimSpace(raw)) - // 单字停用词过滤照拿的是英文逻辑(a / I);汉字单字「梗」「钩」「戏」本身是完整 - // 语义的最小单位。一刀切丢后 tokens 为空,检索会退化成「列全部已启用技能索引」。 - // 这里只放行单个汉字的 token,英文/数字单字与空串仍按停用词丢掉。 - first, size := utf8.DecodeRuneInString(token) - singleHan := size == len(token) && unicode.Is(unicode.Han, first) - if (!singleHan && utf8.RuneCountInString(token) < 2) || seen[token] { - continue - } - seen[token] = true - tokens = append(tokens, token) - if len(tokens) >= cloudAgentSkillSearchTokenMax { - break - } - } - return tokens -} - -func cloudAgentSkillTokenSeparator(r rune) bool { - return unicode.IsSpace(r) || strings.ContainsRune(",,、。;;::/\\|()()[]【】{}<>\"'“”‘’!!??+*&", r) -} - -// cloudAgentSkillCardSlug 取卡路径的文件名(去目录与扩展名),用于关键词匹配。 -// 例:cards/czks-hook-paywall.md → czks-hook-paywall -func cloudAgentSkillCardSlug(path string) string { - base := path - if at := strings.LastIndex(base, "/"); at >= 0 { - base = base[at+1:] - } - for _, ext := range []string{".md", ".txt", ".json"} { - if strings.HasSuffix(base, ext) { - base = strings.TrimSuffix(base, ext) - break - } - } - return strings.ToLower(base) -} - -// cloudAgentSkillCardPaths 返回技能包内除入口以外的全部卡路径(有序)。 -// 这些路径在快照里本来就有(Files 已载入),下发索引不需要读取任何正文。 -func cloudAgentSkillCardPaths(skill cloudAgentSkill) []string { - paths := make([]string, 0, len(skill.Files)) - for path := range skill.Files { - if path == cloudAgentSkillEntryPath { - continue - } - paths = append(paths, path) - } - sort.Strings(paths) - return paths -} - -// cloudAgentSkillMatch 同时给技能与其卡片打分,返回总分与最具体的命中路径。 -// 卡片命中 4 分 > 技能名 3 分 > 描述 2 分:命中最具体的那一层,Agent 才不必先读总纲。 -func cloudAgentSkillMatch(skill cloudAgentSkill, tokens []string) (int, string) { - if len(tokens) == 0 { - return 0, cloudAgentSkillEntryPath - } - name := strings.ToLower(skill.Name) - description := strings.ToLower(skill.Description) - score := 0 - for _, token := range tokens { - switch { - case strings.Contains(name, token): - score += 3 - case strings.Contains(description, token): - score += 2 - } - } - cards := cloudAgentSkillCardPaths(skill) - bestCard := "" - cardScore := 0 - for _, path := range cards { - slug := cloudAgentSkillCardSlug(path) - hit := 0 - for _, token := range tokens { - if strings.Contains(slug, token) { - hit += 4 - } - } - if hit > cardScore { - cardScore = hit - bestCard = path - } - } - score += cardScore - if bestCard != "" { - return score, bestCard - } - return score, cloudAgentSkillEntryPath -} - -// cloudAgentSkillRuneIndex 在 rune 序列里做朴素子串查找,返回 rune 下标(未命中 -1)。 -// 描述只有数百字、token 最多 8 个,朴素查找足够,且避免字节/rune 下标混用。 -func cloudAgentSkillRuneIndex(hay, needle []rune) int { - if len(needle) == 0 || len(needle) > len(hay) { - return -1 - } - for i := 0; i+len(needle) <= len(hay); i++ { - matched := true - for j := range needle { - if unicode.ToLower(hay[i+j]) != unicode.ToLower(needle[j]) { - matched = false - break - } - } - if matched { - return i - } - } - return -1 -} - -func cloudAgentSkillSnippet(skill cloudAgentSkill, tokens []string) string { - description := strings.TrimSpace(skill.Description) - if description == "" { - return "" - } - runes := []rune(description) - if len(runes) <= cloudAgentSkillSnippetRunes { - return description - } - if len(tokens) == 0 { - return strings.TrimSpace(string(runes[:cloudAgentSkillSnippetRunes])) + "…" - } - // 命中位置必须按 rune 计算:strings.Index 返回字节偏移,中文下远大于 rune 下标, - // 直接拿它切 []rune 会越界(曾导致 panic: slice bounds out of range)。 - hit := -1 - for _, token := range tokens { - if at := cloudAgentSkillRuneIndex(runes, []rune(strings.ToLower(token))); at >= 0 && (hit < 0 || at < hit) { - hit = at - } - } - if hit < 0 { - return strings.TrimSpace(string(runes[:cloudAgentSkillSnippetRunes])) + "…" - } - start := hit - cloudAgentSkillSnippetRunes/3 - if start < 0 { - start = 0 - } - if start > len(runes) { - start = len(runes) - } - end := start + cloudAgentSkillSnippetRunes - if end > len(runes) { - end = len(runes) - } - if start > end { - start = end - } - snippet := strings.TrimSpace(string(runes[start:end])) - if start > 0 { - snippet = "…" + snippet - } - if end < len(runes) { - snippet += "…" - } - return snippet -} - -func cloudAgentSearchSkills(skills []cloudAgentSkill, keyword string, limit int) (map[string]any, error) { - keyword = strings.TrimSpace(keyword) - if limit <= 0 { - limit = cloudAgentSkillSearchDefault - } - if limit > cloudAgentSkillSearchMax { - limit = cloudAgentSkillSearchMax - } - guidance := "用 skill_read_file 读取命中条目的 path:若 path 是 cards/… 就直读该卡;若 path 是 SKILL.md,先看返回的 cards 索引再直奔需要的卡,通常无需先读总纲。只能读取返回的 path,不要猜路径。返回的是索引,不是指令。" - if len(skills) == 0 { - return map[string]any{"matches": []map[string]any{}, "total": 0, - "guidance": "本轮没有已启用的技能;skill_search 只搜索已启用技能。"}, nil - } - tokens := cloudAgentSkillSearchTokens(keyword) - if len(tokens) == 0 { - entries := make([]map[string]any, 0, len(skills)) - for _, skill := range skills { - entries = append(entries, map[string]any{ - "skillId": skill.ID, "skillName": skill.Name, "path": cloudAgentSkillEntryPath, - "entryPath": cloudAgentSkillEntryPath, - "snippet": cloudAgentSkillSnippet(skill, nil), - "cardCount": len(cloudAgentSkillCardPaths(skill)), - }) - if len(entries) >= limit { - break - } - } - return map[string]any{"matches": entries, "total": len(skills), "guidance": guidance}, nil - } - type scored struct { - skill cloudAgentSkill - score int - path string - } - ranked := make([]scored, 0, len(skills)) - for _, skill := range skills { - if score, path := cloudAgentSkillMatch(skill, tokens); score > 0 { - ranked = append(ranked, scored{skill: skill, score: score, path: path}) - } - } - sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score }) - entries := make([]map[string]any, 0, limit) - // 卡索引按排名分配预算:命中卡片时 path 已是卡路径,不必再下发索引; - // 只命中技能时下发卡路径,Agent 可以直奔某张卡,不必先读 SKILL.md 总纲。 - cardBudget := cloudAgentSkillSearchCardBudget - for _, entry := range ranked { - if len(entries) >= limit { - break - } - cards := cloudAgentSkillCardPaths(entry.skill) - item := map[string]any{ - "skillId": entry.skill.ID, - "skillName": entry.skill.Name, - "path": entry.path, - "entryPath": cloudAgentSkillEntryPath, - "score": entry.score, - "snippet": cloudAgentSkillSnippet(entry.skill, tokens), - "cardCount": len(cards), - } - if entry.path == cloudAgentSkillEntryPath && len(cards) > 0 && cardBudget > 0 { - shown := cards - if len(shown) > cardBudget { - shown = shown[:cardBudget] - } - cardBudget -= len(shown) - item["cards"] = shown - } - entries = append(entries, item) - } - if len(entries) == 0 { - return map[string]any{"matches": []map[string]any{}, "total": 0, - "guidance": "没有命中「" + keyword + "」的已启用技能。换个说法重试,或先用 skill_search 不带参数列出已启用技能索引,再用 skill_read_file 读取其中的 SKILL.md 与卡。"}, nil - } - return map[string]any{"matches": entries, "total": len(entries), "keyword": keyword, "guidance": guidance}, nil -} - -func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSkill, error) { - snapshots := []cloudAgentSkill{} - for _, id := range ids { - skill, err := s.SkillDetail(userID, id) - if err != nil { - return nil, err - } - if !skill.IsAdded || skill.Status != 1 { - return nil, BadAuthRequest("只能使用用户技能库中已安装且启用的技能") - } - // Skill content is loaded only after the model explicitly calls - // skill_read_file; keep the run context to stable metadata and paths. - // The description is public metadata (market listing) and lets the - // model route between activated skills without reading any body. - snapshot := cloudAgentSkill{ID: id, Name: skill.SkillName, Description: skill.Description, Version: skill.VersionID, Hash: skill.ContentHash, Files: map[string]string{cloudAgentSkillEntryPath: ""}} - files, err := s.SkillPackageFiles(userID, id) - if err != nil { - return nil, err - } - for _, file := range files { - // The entry is listed separately; file bodies are fetched on demand. - if file.Path == cloudAgentSkillEntryPath { - continue - } - // Executable/binary packages are never executed; text references are data only. - if !strings.HasSuffix(file.Path, ".md") && !strings.HasSuffix(file.Path, ".txt") && !strings.HasSuffix(file.Path, ".json") { - continue - } - snapshot.Files[file.Path] = "" - } - // Detect an update during package reads instead of mixing two versions. - latest, err := s.SkillDetail(userID, id) - if err != nil { - return nil, err - } - if latest.VersionID != skill.VersionID || latest.ContentHash != skill.ContentHash { - return nil, creationConflict("技能在读取时已更新,请重试") - } - snapshots = append(snapshots, snapshot) - } - return snapshots, nil -} - func cloudAgentCanonical(system string, history []providerTextMessage, prompt string, req CloudAgentRequest) canonicalAgentRequest { return cloudAgentCanonicalFor(system, history, prompt, req, true) } @@ -694,787 +355,3 @@ func cloudAgentToolAllowed(req CloudAgentRequest, name string) bool { } return false } -func cloudAgentWrite(name string) bool { - return name == "canvas_apply_ops" || name == "canvas_arrange_nodes" || name == "generate_media" || name == "image_layer_split" || name == "canvas_create_storyboard" || name == "canvas_edit_storyboard" || name == "canvas_edit_batch_table" -} - -// 同参缓存只能拦住“原样重复”的读取。模型也可能不断修改 offset、nodeIds 或 -// profile scope 来绕过缓存,因此本轮还要限制所有只读快照工具的累计调用次数。 -// 该上限高于正常画布分页读取所需次数,但足以在异常循环继续消耗模型额度前止损。 -const cloudAgentMaxReadToolCallsPerRun = 32 - -func cloudAgentReadToolCacheable(name string) bool { - switch name { - case "agent_profile_read", "canvas_get_state", "canvas_read_storyboard", "director_scene_read", "skill_read_file", "model_list": - return true - default: - return false - } -} - -// cloudAgentReadToolReadOnly is the single read boundary for the Agent. A -// tool must not be able to bypass the loop budget merely by using a different -// read endpoint. Cacheability is deliberately narrower because task status -// and search results are allowed to change between calls. -func cloudAgentReadToolReadOnly(name string) bool { - switch name { - case "agent_profile_read", "canvas_get_state", "canvas_read_storyboard", "director_scene_read", "canvas_read_batch_table", "canvas_list_node_types", "skill_read_file", "skill_search", "model_list", "recall_lessons", "task_get": - return true - default: - return false - } -} - -func cloudAgentReadCacheKey(call cloudAgentCall) string { - arguments := strings.TrimSpace(call.Function.Arguments) - var value any - if err := json.Unmarshal([]byte(arguments), &value); err == nil { - if object, ok := value.(map[string]any); ok { - // These defaults are semantically identical to omission. Canonicalizing - // them prevents offset=0 retries from bypassing the read cache. - switch call.Function.Name { - case "skill_read_file", "canvas_get_state", "canvas_read_storyboard", "canvas_read_batch_table": - for _, field := range []string{"offset", "connectionOffset", "storyboardOffset"} { - if _, exists := object[field]; !exists { - object[field] = float64(0) - } - } - } - value = object - } - if normalized, err := json.Marshal(value); err == nil { - arguments = string(normalized) - } - } - return call.Function.Name + ":" + arguments -} - -// cloudAgentReadCacheKeyForState binds snapshot reads to the resource version -// that produced them. A read result must never survive an out-of-band canvas or -// skill update merely because the tool arguments stayed the same. -func cloudAgentReadCacheKeyForState(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall) string { - key := cloudAgentReadCacheKey(call) - if state == nil { - return key - } - switch call.Function.Name { - case "canvas_get_state", "canvas_read_storyboard", "director_scene_read": - if repo != nil { - if canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID); err == nil && canvas != nil { - return fmt.Sprintf("%s:canvas-revision:%d", key, canvas.Revision) - } - } - case "skill_read_file": - var args struct { - SkillID string `json:"skillId"` - } - if json.Unmarshal([]byte(call.Function.Arguments), &args) == nil { - for _, skill := range state.Skills { - if skill.ID == args.SkillID { - return fmt.Sprintf("%s:skill-version:%s:%s", key, skill.Version, skill.Hash) - } - } - } - } - return key -} - -func cloudAgentReadToolCached(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) { - if !cloudAgentReadToolReadOnly(call.Function.Name) { - return cloudAgentReadTool(repo, userID, state, call, services...) - } - if state == nil { - return nil, errors.New("Agent 只读工具缺少运行时状态") - } - if !cloudAgentReadToolCacheable(call.Function.Name) { - if state.ReadToolCalls >= cloudAgentMaxReadToolCallsPerRun { - return nil, &cloudAgentReadLoopError{ToolName: call.Function.Name, Count: state.ReadToolCalls + 1, Budget: true, ReasonCode: "read_budget_exceeded"} - } - state.ReadToolCalls++ - return cloudAgentReadTool(repo, userID, state, call, services...) - } - key := cloudAgentReadCacheKeyForState(repo, userID, state, call) - if state.ToolReadResults != nil { - if cached, ok := state.ToolReadResults[key]; ok { - if cached.Error != "" { - // Only deterministic argument errors are persisted. Transient and - // business errors never enter this branch. - cachedErr := errors.New(cached.Error) - if cached.ArgumentError { - return nil, &cloudAgentArgumentError{cachedErr} - } - delete(state.ToolReadResults, key) - } else { - if state.ToolReadReplays == nil { - state.ToolReadReplays = map[string]int{} - } - cached.ReplayCount = state.ToolReadReplays[key] + 1 - state.ToolReadReplays[key] = cached.ReplayCount - state.ToolReadResults[key] = cached - if len(cached.Result) == 0 { - return nil, errors.New("缓存的 Agent 只读结果无效") - } - if !cloudAgentReadResultInContext(state, cached.Result) { - // A compaction may have evicted the original tool body. Restore - // the complete result exactly once so the model can continue. - var restored any - if err := json.Unmarshal(cached.Result, &restored); err != nil { - return nil, errors.New("缓存的 Agent 只读结果无效") - } - return restored, nil - } - return map[string]any{ - "cacheReplay": true, - "replayCount": cached.ReplayCount, - "message": "该只读结果已在当前上下文中,请直接使用已有结果,不要再次读取", - }, nil - } - } - } - if state.ReadToolCalls >= cloudAgentMaxReadToolCallsPerRun { - return nil, &cloudAgentReadLoopError{ToolName: call.Function.Name, Count: state.ReadToolCalls + 1, Budget: true, ReasonCode: "read_budget_exceeded"} - } - // Cache replays are not new reads. Only a cache miss consumes the bounded - // read budget; otherwise a model repeating the same skill/page would still - // terminate after 32 harmless acknowledgements. - state.ReadToolCalls++ - - state.readCacheExecution = true - result, err := cloudAgentReadTool(repo, userID, state, call, services...) - state.readCacheExecution = false - if err != nil { - // Argument errors are deterministic and safe to replay. IO, permission, - // conflict and upstream errors must remain retryable and are not cached. - var argumentErr *cloudAgentArgumentError - if errors.As(err, &argumentErr) { - if state.ToolReadResults == nil { - state.ToolReadResults = map[string]cloudAgentCachedToolResult{} - } - state.ToolReadResults[key] = cloudAgentCachedToolResult{Error: cloudAgentSafeToolError(err), ArgumentError: true} - } - return result, err - } - encoded, marshalErr := json.Marshal(result) - if marshalErr != nil { - return result, marshalErr - } - if state.ToolReadResults == nil { - state.ToolReadResults = map[string]cloudAgentCachedToolResult{} - } - state.ToolReadResults[key] = cloudAgentCachedToolResult{Result: encoded} - return result, nil -} - -func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) { - var service *Service - if len(services) > 0 { - service = services[0] - } - switch call.Function.Name { - case "model_list": - if service == nil { - return nil, BadAuthRequest("模型目录服务不可用") - } - intent, err := service.cloudAgentModelIntent(userID, state.Request.CanvasID, call.Function.Arguments) - if err != nil { - return nil, err - } - return service.cloudAgentModelList(intent) - case "agent_profile_read": - var args struct { - Scope string `json:"scope"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - if args.Scope != model.AgentProfileScopeUser && args.Scope != model.AgentProfileScopeProject && args.Scope != model.AgentProfileScopeCanvas { - return nil, BadAuthRequest("长期偏好作用域无效") - } - if state.ProfileReads == nil { - state.ProfileReads = map[string]bool{} - } - if state.ProfileReads[args.Scope] { - return nil, BadAuthRequest("本轮已读取该长期偏好层,请使用历史工具结果,不要重复读取") - } - available := make([]string, 0, len(state.Profile.Layers)) - for _, layer := range state.Profile.Layers { - available = append(available, layer.Scope) - if layer.Scope == args.Scope { - state.ProfileReads[args.Scope] = true - return map[string]any{"scope": layer.Scope, "revision": layer.Revision, "hash": layer.Hash, "content": layer.Content}, nil - } - } - if len(available) == 0 { - return nil, BadAuthRequest("本轮没有长期偏好层,不要调用 agent_profile_read") - } - return nil, BadAuthRequest("本轮固定快照中不存在该长期偏好层;本轮可读的层只有:" + strings.Join(available, "、") + "。不要再尝试其它层") - case "plan_update": - return cloudAgentApplyPlanUpdate(state, call) - case "ask_user": - return cloudAgentAskUser(call, state) - case "recall_lessons": - return cloudAgentRecallLessons(repo, userID, call) - case "remember_lesson": - return cloudAgentRememberLesson(repo, userID, state, call) - case "director_scene_read": - return cloudAgentDirectorSceneRead(repo, userID, state.Request.CanvasID, call) - case "director_preview": - return cloudAgentDirectorPreview(repo, userID, state.Request.CanvasID, call) - case "canvas_list_node_types": - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &struct{}{}); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - return cloudAgentNodeTypes(), nil - case "canvas_get_state": - var args struct { - Offset int `json:"offset"` - ConnectionOffset int `json:"connectionOffset"` - NodeIDs []string `json:"nodeIds"` - FocusNodeIDs []string `json:"focusNodeIds"` - Depth *int `json:"depth"` - IncludeRelated *bool `json:"includeRelated"` - StoryboardOffset int `json:"storyboardOffset"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - depth := 0 - if args.Depth != nil { - depth = *args.Depth - } - if len(args.FocusNodeIDs) > 0 && args.Depth == nil { - depth = 1 - } - if args.Offset < 0 || args.ConnectionOffset < 0 || args.StoryboardOffset < 0 { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:offset、connectionOffset、storyboardOffset 必须是非负整数")} - } - if len(args.NodeIDs) > 8 { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:nodeIds 最多包含8个节点ID")} - } - if len(args.FocusNodeIDs) > 8 { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:focusNodeIds 最多包含8个节点ID")} - } - if len(args.NodeIDs) > 0 && len(args.FocusNodeIDs) > 0 { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:nodeIds 与 focusNodeIds 互斥")} - } - if depth < 0 || depth > 3 { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:depth 必须为0到3")} - } - if len(args.FocusNodeIDs) == 0 && args.Depth != nil { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:depth 只能与 focusNodeIds 一起使用")} - } - includeRelated := args.IncludeRelated != nil && *args.IncludeRelated - if includeRelated && len(args.FocusNodeIDs) == 0 { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:includeRelated 只能与 focusNodeIds 一起使用")} - } - if includeRelated && args.Depth != nil { - return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:includeRelated 与 depth 不能同时使用")} - } - canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) - if err != nil { - return nil, err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return nil, err - } - if len(args.FocusNodeIDs) > 0 { - if includeRelated { - return cloudAgentCanvasStateWithRelated(repo, userID, state.Request.CanvasID, doc, args.Offset, args.FocusNodeIDs, args.StoryboardOffset, args.ConnectionOffset) - } - return cloudAgentCanvasStateWithFocus(repo, userID, state.Request.CanvasID, doc, args.Offset, args.FocusNodeIDs, depth, args.StoryboardOffset, args.ConnectionOffset) - } - return cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, args.Offset, args.NodeIDs, args.StoryboardOffset, args.ConnectionOffset) - case "canvas_read_storyboard": - var args struct { - NodeID string `json:"nodeId"` - Offset int `json:"offset"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - if err := validateCloudAgentID(args.NodeID, "分镜节点ID", 80); err != nil || args.Offset < 0 { - return nil, BadAuthRequest("分镜节点ID或分页参数无效") - } - canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) - if err != nil { - return nil, err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return nil, err - } - if _, _, _, err := storyboardNodeFromDocument(doc, args.NodeID); err != nil { - return nil, err - } - view, err := cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, 0, []string{args.NodeID}, args.Offset) - if err != nil { - return nil, err - } - return cloudAgentStoryboardReadResult(view, args.NodeID, cloudAgentNodeHash(doc, args.NodeID)) - case "canvas_read_batch_table": - var args struct { - NodeID string `json:"nodeId"` - Offset int `json:"offset"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - if err := validateCloudAgentID(args.NodeID, "批量创作表节点ID", 80); err != nil || args.Offset < 0 { - return nil, BadAuthRequest("批量创作表节点ID或分页参数无效") - } - canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) - if err != nil { - return nil, err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return nil, err - } - if _, _, _, _, err := batchTableNodeFromDocument(doc, args.NodeID); err != nil { - return nil, err - } - view, err := cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, 0, []string{args.NodeID}, args.Offset) - if err != nil { - return nil, err - } - return cloudAgentBatchTableReadResult(view, args.NodeID, cloudAgentNodeHash(doc, args.NodeID)) - case "image_text_detect": - var args struct { - NodeID string `json:"nodeId"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - if err := validateCloudAgentID(args.NodeID, "图片节点ID", 80); err != nil { - return nil, err - } - canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) - if err != nil { - return nil, err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return nil, err - } - nodes, err := creationObjects(doc["nodes"]) - if err != nil { - return nil, err - } - node := nodes[args.NodeID] - if node == nil || stringValue(node["type"]) != "image" { - return nil, BadAuthRequest("目标节点不是图片节点") - } - ref, _, err := cloudAgentReference(repo, userID, node) - if err != nil { - return nil, err - } - return map[string]any{"nodeId": args.NodeID, "reference": ref, "status": "ready_for_visual_detection", "outputSchema": []string{"original", "text", "location"}, "nextStep": "使用视觉模型对该参考图返回 JSON 数组;不要把识别结果写回画布"}, nil - case "image_annotation_render": - if len(services) == 0 || services[0] == nil { - return nil, BadAuthRequest("标注资源存储不可用") - } - return cloudAgentRenderImageAnnotations(repo, userID, state, call, services[0]) - case "skill_search": - var args struct { - Keyword string `json:"keyword"` - Limit int `json:"limit"` - // 容忍模型顺手带上的 skillId(对齐 skill_read_file 的参数习惯): - // 检索范围恒为本轮已启用技能,该字段仅接收不生效。 - SkillID string `json:"skillId,omitempty"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - return cloudAgentSearchSkills(state.Skills, args.Keyword, args.Limit) - case "skill_read_file": - var args struct { - SkillID string `json:"skillId"` - Path string `json:"path"` - Offset int `json:"offset"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - if args.Offset < 0 || (args.Path == "" && args.Offset != 0) { - return nil, BadAuthRequest("技能读取偏移无效") - } - for _, skill := range state.Skills { - if skill.ID == args.SkillID { - if args.Path == "" { - return map[string]any{"version": skill.Version, "entryPath": cloudAgentSkillEntryPath, "files": cloudAgentSkillPaths(skill), "guidance": "先读取 SKILL.md,再只读取入口明确引用且当前任务需要的参考文件。只能读取 files 中列出的路径;不要重复列目录或猜测路径"}, nil - } - if service != nil { - detail, err := service.SkillDetail(userID, skill.ID) - if err != nil { - return nil, err - } - if !detail.IsAdded || detail.Status != 1 || detail.VersionID != skill.Version || detail.ContentHash != skill.Hash { - return nil, creationConflict("技能已更新或不可用,请重试") - } - if args.Path == cloudAgentSkillEntryPath { - return cloudAgentSkillPage(skill.Version, args.Path, detail.Instruction, args.Offset) - } - if _, ok := skill.Files[args.Path]; !ok { - return nil, BadAuthRequest("参考文件未包含在本轮固定快照中") - } - file, err := service.SkillPackageFile(userID, skill.ID, args.Path) - if err != nil { - return nil, err - } - if file.Binary { - return nil, BadAuthRequest("不支持读取二进制技能文件") - } - latest, err := service.SkillDetail(userID, skill.ID) - if err != nil { - return nil, err - } - if !latest.IsAdded || latest.Status != 1 || latest.VersionID != skill.Version || latest.ContentHash != skill.Hash { - return nil, creationConflict("技能已更新或不可用,请重试") - } - return cloudAgentSkillPage(skill.Version, args.Path, file.Content, args.Offset) - } - if args.Path == cloudAgentSkillEntryPath && strings.TrimSpace(skill.Instruction) != "" { - return map[string]any{"version": skill.Version, "path": args.Path, "content": skill.Instruction}, nil - } - if content, ok := skill.Files[args.Path]; ok && content != "" { - return map[string]any{"version": skill.Version, "path": args.Path, "content": content}, nil - } - return nil, BadAuthRequest(fmt.Sprintf("参考文件未包含在本轮固定快照中;可读路径:%s。不要重试此路径", strings.Join(cloudAgentSkillPaths(skill), ", "))) - } - } - return nil, BadAuthRequest("技能未在本轮启用,或参考文件未包含在固定快照中") - case "task_get": - var args struct { - TaskID string `json:"taskId"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - task, err := repo.TaskForUser(userID, args.TaskID) - if err != nil { - return nil, err - } - if task.ProjectID != state.Request.CanvasID { - return nil, BadAuthRequest("不能读取其他画布的任务") - } - result := cloudAgentTaskDiagnostic(repo, task) - result["status"] = task.Status - result["text"] = truncateRunes(taskResultText(task.ResultJSON), 4000) - return result, nil - } - return nil, BadAuthRequest("未知工具") -} - -func cloudAgentRenderImageAnnotations(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, service *Service) (any, error) { - var args struct { - NodeID string `json:"nodeId"` - Annotations []struct { - Label string `json:"label"` - X float64 `json:"x"` - Y float64 `json:"y"` - } `json:"annotations"` - } - if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { - return nil, cloudAgentJSONArgumentError(err) - } - if err := validateCloudAgentID(args.NodeID, "图片节点ID", 80); err != nil { - return nil, err - } - if len(args.Annotations) == 0 || len(args.Annotations) > 30 { - return nil, BadAuthRequest("标注数量必须在1到30之间") - } - canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) - if err != nil { - return nil, err - } - doc, err := creationDocument(canvas.PayloadJSON) - if err != nil { - return nil, err - } - nodes, err := creationObjects(doc["nodes"]) - if err != nil { - return nil, err - } - node := nodes[args.NodeID] - if node == nil || stringValue(node["type"]) != "image" { - return nil, BadAuthRequest("目标节点不是图片节点") - } - width, height := 1024.0, 1024.0 - if v, ok := node["width"].(float64); ok && v > 0 { - width = v - } - if v, ok := node["height"].(float64); ok && v > 0 { - height = v - } - if width < 1 || height < 1 || width > 8192 || height > 8192 || width*height > 16_777_216 { - return nil, BadAuthRequest("标注图片尺寸超过 1600 万像素预算,请先缩小图片节点") - } - if state.RuntimeRunID == "" { - return nil, BadAuthRequest("标注缺少运行归属") - } - canvasImage := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) - red := color.RGBA{R: 239, G: 68, B: 68, A: 255} - white := color.RGBA{R: 255, G: 255, B: 255, A: 255} - for _, item := range args.Annotations { - if item.X < 0 || item.X > 1 || item.Y < 0 || item.Y > 1 || strings.TrimSpace(item.Label) == "" { - return nil, BadAuthRequest("标注坐标必须在0到1之间且文字不能为空") - } - x, y := int(item.X*width), int(item.Y*height) - drawFilledCircle(canvasImage, x, y, 18, red) - // A white center keeps markers visually distinct on dark and light images. - drawFilledCircle(canvasImage, x, y, 8, white) - } - var encoded bytes.Buffer - if err := png.Encode(&encoded, canvasImage); err != nil { - return nil, fmt.Errorf("编码标注参考图失败: %w", err) - } - if state.TransientReferences == nil { - state.TransientReferences = map[string]cloudAgentTransientReference{} - } - refID := "annotation-" + call.ID - identity := "agent-annotation:" + state.RuntimeRunID + ":" + call.ID + ":" + creationHash(call.Function.Arguments) - resource, err := service.UploadResourceFile(userID, "annotation-overlay.png", int64(encoded.Len()), "image", int(width), int(height), 0, bytes.NewReader(encoded.Bytes()), identity) - if err != nil { - return nil, err - } - expiresAt := time.Now().Add(24 * time.Hour) - if err := repo.UpsertCloudAgentResourceLeases(userID, state.RuntimeRunID, "annotation:"+call.ID, []string{resource.ID}, expiresAt); err != nil { - return nil, err - } - state.TransientReferences[refID] = cloudAgentTransientReference{ID: refID, Name: "annotation-overlay.png", MIMEType: "image/png", ResourceID: resource.ID, ExpiresAt: expiresAt} - return map[string]any{"nodeId": args.NodeID, "width": width, "height": height, "annotationCount": len(args.Annotations), "referenceTransientId": refID, "mimeType": "image/png", "referenceOrder": []string{args.NodeID, refID}, "expiresAt": expiresAt, "persisted": true}, nil -} - -func drawFilledCircle(dst draw.Image, cx, cy, radius int, fill color.Color) { - for y := cy - radius; y <= cy+radius; y++ { - for x := cx - radius; x <= cx+radius; x++ { - dx, dy := x-cx, y-cy - if dx*dx+dy*dy <= radius*radius { - dst.Set(x, y, fill) - } - } - } -} - -func cloudAgentSkillPage(version, path, content string, offset int) (any, error) { - runes := []rune(content) - if offset < 0 || offset > len(runes) { - return nil, BadAuthRequest("技能读取偏移超出文件范围") - } - end := offset + min(12000, len(runes)-offset) - return map[string]any{"version": version, "path": path, "content": string(runes[offset:end]), "offset": offset, "nextOffset": end, "hasMore": end < len(runes)}, nil -} - -func validateCloudAgentID(value, label string, maxRunes int) error { - if value == "" || strings.TrimSpace(value) != value || !utf8.ValidString(value) { - return BadAuthRequest(label + "不能为空、不能包含首尾空白或无效字符") - } - if utf8.RuneCountInString(value) > maxRunes { - return BadAuthRequest(fmt.Sprintf("%s不能超过 %d 个字符", label, maxRunes)) - } - for _, r := range value { - if unicode.IsControl(r) { - return BadAuthRequest(label + "不能包含控制字符") - } - } - return nil -} - -type agentCanvasArgs struct { - SnapshotHash string `json:"snapshotHash"` - Ops []agentCanvasOp `json:"ops"` -} - -type agentCanvasOp struct { - Type string `json:"type"` - ID string `json:"id"` - NodeType string `json:"nodeType"` - Title *string `json:"title"` - Content *string `json:"content"` - Patch map[string]any `json:"patch"` - // X/Y 为指针:nil 表示模型没有指定坐标,服务端按画布内容自动落位(不再落到原点重叠)。 - // 指针语义与 canvas/capability/builtin.go 的 positionPatchFields 一致(坐标是可选的数字)。 - X *float64 `json:"x"` - Y *float64 `json:"y"` - FromNodeID string `json:"fromNodeId"` - ToNodeID string `json:"toNodeId"` -} - -// Explicit node creation and edges only; no generic metadata, media URL or deletion. -func applyCloudAgentCanvas(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) { - plan, err := prepareCloudAgentCanvasMutation(repo, userID, canvasID, call) - if err != nil { - return nil, err - } - if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil { - return nil, err - } - if len(recorder) > 0 && recorder[0] != nil { - if err := recorder[0](repo, cloudAgentMutationInput{ - UserID: userID, - CanvasID: canvasID, - StepID: call.ID, - Operation: "canvas_apply_ops", - BeforeSnapshotHash: plan.BeforeSnapshotHash, - AfterSnapshotHash: cloudAgentCanvasHash(plan.Document), - BeforeJSON: plan.BeforeJSON, - Preview: &plan.Preview, - }); err != nil { - return nil, err - } - } - return map[string]any{"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "summary": fmt.Sprintf("已完成 %d 项节点/连线操作", len(plan.Args.Ops)), "preview": plan.Preview}, nil -} - -func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, existingConnections ...[]map[string]any) error { - if err := validateCloudAgentID(fromID, "来源节点 ID", 80); err != nil { - return err - } - if err := validateCloudAgentID(toID, "目标节点 ID", 80); err != nil { - return err - } - if fromID == toID { - return BadAuthRequest("连线不能指向自身") - } - var from, to map[string]any - for _, node := range nodes { - if stringValue(node["id"]) == fromID { - from = node - } - if stringValue(node["id"]) == toID { - to = node - } - } - if from == nil || to == nil { - return BadAuthRequest("连线端点不存在") - } - fromCapability, fromKnown := cloudAgentNodeCapabilityForType(stringValue(from["type"])) - toCapability, toKnown := cloudAgentNodeCapabilityForType(stringValue(to["type"])) - if !fromKnown || !toKnown { - return BadAuthRequest("连线包含当前 Agent 不支持的节点类型") - } - fromKind := fromCapability.InputKind - if fromKind == "" || !fromCapability.Connection.CanSource { - return BadAuthRequest(fmt.Sprintf("来源节点类型 %s 不能作为生成输入;引用连线不能用于普通节点关联", fromCapability.Type)) - } - if !toCapability.Connection.CanTarget { - return BadAuthRequest(fmt.Sprintf("目标节点类型 %s 不能接收生成输入;无需为文档归档建立引用连线", toCapability.Type)) - } - connections := []map[string]any{} - if len(existingConnections) > 0 { - connections = existingConnections[0] - } - for _, edge := range connections { - if stringValue(edge["toNodeId"]) == toID && stringValue(edge["fromNodeId"]) == fromID { - return BadAuthRequest("连线重复") - } - } - if err := toCapability.ValidateConnection(fromKind); err != nil { - return BadAuthRequest(err.Error()) - } - if maxInputs := toCapability.Connection.MaxInputCount; maxInputs > 0 { - inputIDs := map[string]bool{} - for _, edge := range connections { - if stringValue(edge["toNodeId"]) == toID { - inputIDs[stringValue(edge["fromNodeId"])] = true - } - } - inputIDs[fromID] = true - if len(inputIDs) > maxInputs { - return BadAuthRequest(fmt.Sprintf("%s最多连接 %d 个输入", toCapability.Label, maxInputs)) - } - } - return nil -} - -func cloudAgentInputKindLabel(kind string) string { - switch kind { - case "image": - return "图片" - case "video": - return "视频" - case "audio": - return "音频" - default: - return "文本" - } -} - -func creationMaps(value any) []map[string]any { - result := []map[string]any{} - switch items := value.(type) { - case []any: - for _, v := range items { - if m, ok := v.(map[string]any); ok { - result = append(result, m) - } - } - case []map[string]any: - result = items - } - return result -} - -// Only server-registered canvas capabilities are exposed to the model. UI-only -// renderers are not a persistence or authorization contract. -func cloudAgentNodeTypes() map[string]any { - types := make([]map[string]any, 0, len(canvasCapabilityRegistry.List())) - for _, capability := range canvasCapabilityRegistry.List() { - item := map[string]any{ - "type": capability.Type, - "label": capability.Label, - "purpose": capability.Purpose, - "defaultSize": map[string]any{"width": capability.DefaultWidth, "height": capability.DefaultHeight}, - "canUpdate": capability.CanUpdate, - } - if len(capability.GoodFor) > 0 { - item["goodFor"] = capability.GoodFor - } - if len(capability.NotIdealFor) > 0 { - item["notIdealFor"] = capability.NotIdealFor - } - if len(capability.Tradeoffs) > 0 { - item["tradeoffs"] = capability.Tradeoffs - } - if len(capability.Actions) > 0 { - item["actions"] = capability.Actions - } - if capability.CanUpdate { - fields := map[string]any{} - for key, field := range capability.PatchFields { - definition := map[string]any{"type": field.Kind, "label": field.Label, "displayOrder": field.Order, "maxCharacters": field.MaxRunes} - if field.Description != "" { - definition["description"] = field.Description - } - fields[key] = definition - } - item["updateFields"] = fields - } - if capability.InputKind != "" { - item["inputKind"] = capability.InputKind - } - if capability.GenerationMode != "" && cloudAgentGenerationModeSupported(capability.GenerationMode) { - item["generationMode"] = capability.GenerationMode - } - if len(capability.Connection.AcceptedInputKinds) > 0 { - item["acceptedInputKinds"] = capability.Connection.AcceptedInputKinds - } - if len(capability.Connection.RejectedInputKinds) > 0 { - item["rejectedInputKinds"] = capability.Connection.RejectedInputKinds - } - if capability.Connection.MaxInputCount > 0 { - item["maxInputCount"] = capability.Connection.MaxInputCount - } - item["canSource"] = capability.Connection.CanSource - item["canTarget"] = capability.Connection.CanTarget - item["canReference"] = capability.Connection.CanReference - types = append(types, item) - } - return map[string]any{"schemaVersion": 2, "nodes": types, "selectionGuide": []string{ - "单个画面、一次性提示词或快速试验通常使用文本/Markdown与媒体节点更轻量。", - "多镜头、连续性、逐镜审查、逐镜生成或需要后续维护时,分镜脚本通常更合适。", - "媒体节点只承载单个生成目标,不替代多镜头结构;选择媒体节点后还要用 model_list 按生成模式和本次真实参考节点筛选模型。", - "节点选择由Agent结合用户目标决定;不要为了形式创建复杂节点,也不要用普通文本伪装成结构化分镜。", - }} -} diff --git a/backend/internal/app/cloud_agent_tools_canvas.go b/backend/internal/app/cloud_agent_tools_canvas.go new file mode 100644 index 000000000..1caa7ad62 --- /dev/null +++ b/backend/internal/app/cloud_agent_tools_canvas.go @@ -0,0 +1,237 @@ +// 画布 Agent 的画布写操作:把工具参数转换成画布操作(增删节点、连线)并校验。 +// +// 连线必须满足节点类型的输入约束(validateCloudAgentConnection),不合法的连线 +// 直接拒绝并返回可读原因,由模型自行修正。 + +package app + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "infinite-canvas/backend/internal/repository" +) + +func cloudAgentSkillPage(version, path, content string, offset int) (any, error) { + runes := []rune(content) + if offset < 0 || offset > len(runes) { + return nil, BadAuthRequest("技能读取偏移超出文件范围") + } + end := offset + min(12000, len(runes)-offset) + return map[string]any{"version": version, "path": path, "content": string(runes[offset:end]), "offset": offset, "nextOffset": end, "hasMore": end < len(runes)}, nil +} + +func validateCloudAgentID(value, label string, maxRunes int) error { + if value == "" || strings.TrimSpace(value) != value || !utf8.ValidString(value) { + return BadAuthRequest(label + "不能为空、不能包含首尾空白或无效字符") + } + if utf8.RuneCountInString(value) > maxRunes { + return BadAuthRequest(fmt.Sprintf("%s不能超过 %d 个字符", label, maxRunes)) + } + for _, r := range value { + if unicode.IsControl(r) { + return BadAuthRequest(label + "不能包含控制字符") + } + } + return nil +} + +type agentCanvasArgs struct { + SnapshotHash string `json:"snapshotHash"` + Ops []agentCanvasOp `json:"ops"` +} + +type agentCanvasOp struct { + Type string `json:"type"` + ID string `json:"id"` + NodeType string `json:"nodeType"` + Title *string `json:"title"` + Content *string `json:"content"` + Patch map[string]any `json:"patch"` + // X/Y 为指针:nil 表示模型没有指定坐标,服务端按画布内容自动落位(不再落到原点重叠)。 + // 指针语义与 canvas/capability/builtin.go 的 positionPatchFields 一致(坐标是可选的数字)。 + X *float64 `json:"x"` + Y *float64 `json:"y"` + FromNodeID string `json:"fromNodeId"` + ToNodeID string `json:"toNodeId"` +} + +// Explicit node creation and edges only; no generic metadata, media URL or deletion. +func applyCloudAgentCanvas(repo *repository.Repository, userID, canvasID string, call cloudAgentCall, policy RuntimePolicySetting, recorder ...cloudAgentMutationRecorder) (any, error) { + plan, err := prepareCloudAgentCanvasMutation(repo, userID, canvasID, call) + if err != nil { + return nil, err + } + if err = saveCloudAgentDocument(repo, plan.Canvas, plan.Document, policy); err != nil { + return nil, err + } + if len(recorder) > 0 && recorder[0] != nil { + if err := recorder[0](repo, cloudAgentMutationInput{ + UserID: userID, + CanvasID: canvasID, + StepID: call.ID, + Operation: "canvas_apply_ops", + BeforeSnapshotHash: plan.BeforeSnapshotHash, + AfterSnapshotHash: cloudAgentCanvasHash(plan.Document), + BeforeJSON: plan.BeforeJSON, + Preview: &plan.Preview, + }); err != nil { + return nil, err + } + } + return map[string]any{"canvasId": canvasID, "snapshotHash": cloudAgentCanvasHash(plan.Document), "summary": fmt.Sprintf("已完成 %d 项节点/连线操作", len(plan.Args.Ops)), "preview": plan.Preview}, nil +} + +func validateCloudAgentConnection(nodes []map[string]any, fromID, toID string, existingConnections ...[]map[string]any) error { + if err := validateCloudAgentID(fromID, "来源节点 ID", 80); err != nil { + return err + } + if err := validateCloudAgentID(toID, "目标节点 ID", 80); err != nil { + return err + } + if fromID == toID { + return BadAuthRequest("连线不能指向自身") + } + var from, to map[string]any + for _, node := range nodes { + if stringValue(node["id"]) == fromID { + from = node + } + if stringValue(node["id"]) == toID { + to = node + } + } + if from == nil || to == nil { + return BadAuthRequest("连线端点不存在") + } + fromCapability, fromKnown := cloudAgentNodeCapabilityForType(stringValue(from["type"])) + toCapability, toKnown := cloudAgentNodeCapabilityForType(stringValue(to["type"])) + if !fromKnown || !toKnown { + return BadAuthRequest("连线包含当前 Agent 不支持的节点类型") + } + fromKind := fromCapability.InputKind + if fromKind == "" || !fromCapability.Connection.CanSource { + return BadAuthRequest(fmt.Sprintf("来源节点类型 %s 不能作为生成输入;引用连线不能用于普通节点关联", fromCapability.Type)) + } + if !toCapability.Connection.CanTarget { + return BadAuthRequest(fmt.Sprintf("目标节点类型 %s 不能接收生成输入;无需为文档归档建立引用连线", toCapability.Type)) + } + connections := []map[string]any{} + if len(existingConnections) > 0 { + connections = existingConnections[0] + } + for _, edge := range connections { + if stringValue(edge["toNodeId"]) == toID && stringValue(edge["fromNodeId"]) == fromID { + return BadAuthRequest("连线重复") + } + } + if err := toCapability.ValidateConnection(fromKind); err != nil { + return BadAuthRequest(err.Error()) + } + if maxInputs := toCapability.Connection.MaxInputCount; maxInputs > 0 { + inputIDs := map[string]bool{} + for _, edge := range connections { + if stringValue(edge["toNodeId"]) == toID { + inputIDs[stringValue(edge["fromNodeId"])] = true + } + } + inputIDs[fromID] = true + if len(inputIDs) > maxInputs { + return BadAuthRequest(fmt.Sprintf("%s最多连接 %d 个输入", toCapability.Label, maxInputs)) + } + } + return nil +} + +func cloudAgentInputKindLabel(kind string) string { + switch kind { + case "image": + return "图片" + case "video": + return "视频" + case "audio": + return "音频" + default: + return "文本" + } +} + +func creationMaps(value any) []map[string]any { + result := []map[string]any{} + switch items := value.(type) { + case []any: + for _, v := range items { + if m, ok := v.(map[string]any); ok { + result = append(result, m) + } + } + case []map[string]any: + result = items + } + return result +} + +// Only server-registered canvas capabilities are exposed to the model. UI-only +// renderers are not a persistence or authorization contract. +func cloudAgentNodeTypes() map[string]any { + types := make([]map[string]any, 0, len(canvasCapabilityRegistry.List())) + for _, capability := range canvasCapabilityRegistry.List() { + item := map[string]any{ + "type": capability.Type, + "label": capability.Label, + "purpose": capability.Purpose, + "defaultSize": map[string]any{"width": capability.DefaultWidth, "height": capability.DefaultHeight}, + "canUpdate": capability.CanUpdate, + } + if len(capability.GoodFor) > 0 { + item["goodFor"] = capability.GoodFor + } + if len(capability.NotIdealFor) > 0 { + item["notIdealFor"] = capability.NotIdealFor + } + if len(capability.Tradeoffs) > 0 { + item["tradeoffs"] = capability.Tradeoffs + } + if len(capability.Actions) > 0 { + item["actions"] = capability.Actions + } + if capability.CanUpdate { + fields := map[string]any{} + for key, field := range capability.PatchFields { + definition := map[string]any{"type": field.Kind, "label": field.Label, "displayOrder": field.Order, "maxCharacters": field.MaxRunes} + if field.Description != "" { + definition["description"] = field.Description + } + fields[key] = definition + } + item["updateFields"] = fields + } + if capability.InputKind != "" { + item["inputKind"] = capability.InputKind + } + if capability.GenerationMode != "" && cloudAgentGenerationModeSupported(capability.GenerationMode) { + item["generationMode"] = capability.GenerationMode + } + if len(capability.Connection.AcceptedInputKinds) > 0 { + item["acceptedInputKinds"] = capability.Connection.AcceptedInputKinds + } + if len(capability.Connection.RejectedInputKinds) > 0 { + item["rejectedInputKinds"] = capability.Connection.RejectedInputKinds + } + if capability.Connection.MaxInputCount > 0 { + item["maxInputCount"] = capability.Connection.MaxInputCount + } + item["canSource"] = capability.Connection.CanSource + item["canTarget"] = capability.Connection.CanTarget + item["canReference"] = capability.Connection.CanReference + types = append(types, item) + } + return map[string]any{"schemaVersion": 2, "nodes": types, "selectionGuide": []string{ + "单个画面、一次性提示词或快速试验通常使用文本/Markdown与媒体节点更轻量。", + "多镜头、连续性、逐镜审查、逐镜生成或需要后续维护时,分镜脚本通常更合适。", + "媒体节点只承载单个生成目标,不替代多镜头结构;选择媒体节点后还要用 model_list 按生成模式和本次真实参考节点筛选模型。", + "节点选择由Agent结合用户目标决定;不要为了形式创建复杂节点,也不要用普通文本伪装成结构化分镜。", + }} +} diff --git a/backend/internal/app/cloud_agent_tools_read.go b/backend/internal/app/cloud_agent_tools_read.go new file mode 100644 index 000000000..8411d0b4d --- /dev/null +++ b/backend/internal/app/cloud_agent_tools_read.go @@ -0,0 +1,585 @@ +// 画布 Agent 的只读工具:读取画布、节点、任务等,并按「工具 + 参数 + 画布快照」缓存结果。 +// +// 单轮读取次数有上限(cloudAgentMaxReadToolCallsPerRun),防止模型陷入反复读取的循环。 +// 图片标注(cloudAgentRenderImageAnnotations)在服务端合成,模型只拿到合成后的图。 + +package app + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "image" + "image/color" + "image/draw" + "image/png" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func cloudAgentWrite(name string) bool { + return name == "canvas_apply_ops" || name == "canvas_arrange_nodes" || name == "generate_media" || name == "image_layer_split" || name == "canvas_create_storyboard" || name == "canvas_edit_storyboard" || name == "canvas_edit_batch_table" +} + +// 同参缓存只能拦住“原样重复”的读取。模型也可能不断修改 offset、nodeIds 或 +// profile scope 来绕过缓存,因此本轮还要限制所有只读快照工具的累计调用次数。 +// 该上限高于正常画布分页读取所需次数,但足以在异常循环继续消耗模型额度前止损。 +const cloudAgentMaxReadToolCallsPerRun = 32 + +func cloudAgentReadToolCacheable(name string) bool { + switch name { + case "agent_profile_read", "canvas_get_state", "canvas_read_storyboard", "director_scene_read", "skill_read_file", "model_list": + return true + default: + return false + } +} + +// cloudAgentReadToolReadOnly is the single read boundary for the Agent. A +// tool must not be able to bypass the loop budget merely by using a different +// read endpoint. Cacheability is deliberately narrower because task status +// and search results are allowed to change between calls. +func cloudAgentReadToolReadOnly(name string) bool { + switch name { + case "agent_profile_read", "canvas_get_state", "canvas_read_storyboard", "director_scene_read", "canvas_read_batch_table", "canvas_list_node_types", "skill_read_file", "skill_search", "model_list", "recall_lessons", "task_get": + return true + default: + return false + } +} + +func cloudAgentReadCacheKey(call cloudAgentCall) string { + arguments := strings.TrimSpace(call.Function.Arguments) + var value any + if err := json.Unmarshal([]byte(arguments), &value); err == nil { + if object, ok := value.(map[string]any); ok { + // These defaults are semantically identical to omission. Canonicalizing + // them prevents offset=0 retries from bypassing the read cache. + switch call.Function.Name { + case "skill_read_file", "canvas_get_state", "canvas_read_storyboard", "canvas_read_batch_table": + for _, field := range []string{"offset", "connectionOffset", "storyboardOffset"} { + if _, exists := object[field]; !exists { + object[field] = float64(0) + } + } + } + value = object + } + if normalized, err := json.Marshal(value); err == nil { + arguments = string(normalized) + } + } + return call.Function.Name + ":" + arguments +} + +// cloudAgentReadCacheKeyForState binds snapshot reads to the resource version +// that produced them. A read result must never survive an out-of-band canvas or +// skill update merely because the tool arguments stayed the same. +func cloudAgentReadCacheKeyForState(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall) string { + key := cloudAgentReadCacheKey(call) + if state == nil { + return key + } + switch call.Function.Name { + case "canvas_get_state", "canvas_read_storyboard", "director_scene_read": + if repo != nil { + if canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID); err == nil && canvas != nil { + return fmt.Sprintf("%s:canvas-revision:%d", key, canvas.Revision) + } + } + case "skill_read_file": + var args struct { + SkillID string `json:"skillId"` + } + if json.Unmarshal([]byte(call.Function.Arguments), &args) == nil { + for _, skill := range state.Skills { + if skill.ID == args.SkillID { + return fmt.Sprintf("%s:skill-version:%s:%s", key, skill.Version, skill.Hash) + } + } + } + } + return key +} + +func cloudAgentReadToolCached(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) { + if !cloudAgentReadToolReadOnly(call.Function.Name) { + return cloudAgentReadTool(repo, userID, state, call, services...) + } + if state == nil { + return nil, errors.New("Agent 只读工具缺少运行时状态") + } + if !cloudAgentReadToolCacheable(call.Function.Name) { + if state.ReadToolCalls >= cloudAgentMaxReadToolCallsPerRun { + return nil, &cloudAgentReadLoopError{ToolName: call.Function.Name, Count: state.ReadToolCalls + 1, Budget: true, ReasonCode: "read_budget_exceeded"} + } + state.ReadToolCalls++ + return cloudAgentReadTool(repo, userID, state, call, services...) + } + key := cloudAgentReadCacheKeyForState(repo, userID, state, call) + if state.ToolReadResults != nil { + if cached, ok := state.ToolReadResults[key]; ok { + if cached.Error != "" { + // Only deterministic argument errors are persisted. Transient and + // business errors never enter this branch. + cachedErr := errors.New(cached.Error) + if cached.ArgumentError { + return nil, &cloudAgentArgumentError{cachedErr} + } + delete(state.ToolReadResults, key) + } else { + if state.ToolReadReplays == nil { + state.ToolReadReplays = map[string]int{} + } + cached.ReplayCount = state.ToolReadReplays[key] + 1 + state.ToolReadReplays[key] = cached.ReplayCount + state.ToolReadResults[key] = cached + if len(cached.Result) == 0 { + return nil, errors.New("缓存的 Agent 只读结果无效") + } + if !cloudAgentReadResultInContext(state, cached.Result) { + // A compaction may have evicted the original tool body. Restore + // the complete result exactly once so the model can continue. + var restored any + if err := json.Unmarshal(cached.Result, &restored); err != nil { + return nil, errors.New("缓存的 Agent 只读结果无效") + } + return restored, nil + } + return map[string]any{ + "cacheReplay": true, + "replayCount": cached.ReplayCount, + "message": "该只读结果已在当前上下文中,请直接使用已有结果,不要再次读取", + }, nil + } + } + } + if state.ReadToolCalls >= cloudAgentMaxReadToolCallsPerRun { + return nil, &cloudAgentReadLoopError{ToolName: call.Function.Name, Count: state.ReadToolCalls + 1, Budget: true, ReasonCode: "read_budget_exceeded"} + } + // Cache replays are not new reads. Only a cache miss consumes the bounded + // read budget; otherwise a model repeating the same skill/page would still + // terminate after 32 harmless acknowledgements. + state.ReadToolCalls++ + + state.readCacheExecution = true + result, err := cloudAgentReadTool(repo, userID, state, call, services...) + state.readCacheExecution = false + if err != nil { + // Argument errors are deterministic and safe to replay. IO, permission, + // conflict and upstream errors must remain retryable and are not cached. + var argumentErr *cloudAgentArgumentError + if errors.As(err, &argumentErr) { + if state.ToolReadResults == nil { + state.ToolReadResults = map[string]cloudAgentCachedToolResult{} + } + state.ToolReadResults[key] = cloudAgentCachedToolResult{Error: cloudAgentSafeToolError(err), ArgumentError: true} + } + return result, err + } + encoded, marshalErr := json.Marshal(result) + if marshalErr != nil { + return result, marshalErr + } + if state.ToolReadResults == nil { + state.ToolReadResults = map[string]cloudAgentCachedToolResult{} + } + state.ToolReadResults[key] = cloudAgentCachedToolResult{Result: encoded} + return result, nil +} + +func cloudAgentReadTool(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, services ...*Service) (any, error) { + var service *Service + if len(services) > 0 { + service = services[0] + } + switch call.Function.Name { + case "model_list": + if service == nil { + return nil, BadAuthRequest("模型目录服务不可用") + } + intent, err := service.cloudAgentModelIntent(userID, state.Request.CanvasID, call.Function.Arguments) + if err != nil { + return nil, err + } + return service.cloudAgentModelList(intent) + case "agent_profile_read": + var args struct { + Scope string `json:"scope"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + if args.Scope != model.AgentProfileScopeUser && args.Scope != model.AgentProfileScopeProject && args.Scope != model.AgentProfileScopeCanvas { + return nil, BadAuthRequest("长期偏好作用域无效") + } + if state.ProfileReads == nil { + state.ProfileReads = map[string]bool{} + } + if state.ProfileReads[args.Scope] { + return nil, BadAuthRequest("本轮已读取该长期偏好层,请使用历史工具结果,不要重复读取") + } + available := make([]string, 0, len(state.Profile.Layers)) + for _, layer := range state.Profile.Layers { + available = append(available, layer.Scope) + if layer.Scope == args.Scope { + state.ProfileReads[args.Scope] = true + return map[string]any{"scope": layer.Scope, "revision": layer.Revision, "hash": layer.Hash, "content": layer.Content}, nil + } + } + if len(available) == 0 { + return nil, BadAuthRequest("本轮没有长期偏好层,不要调用 agent_profile_read") + } + return nil, BadAuthRequest("本轮固定快照中不存在该长期偏好层;本轮可读的层只有:" + strings.Join(available, "、") + "。不要再尝试其它层") + case "plan_update": + return cloudAgentApplyPlanUpdate(state, call) + case "ask_user": + return cloudAgentAskUser(call, state) + case "recall_lessons": + return cloudAgentRecallLessons(repo, userID, call) + case "remember_lesson": + return cloudAgentRememberLesson(repo, userID, state, call) + case "director_scene_read": + return cloudAgentDirectorSceneRead(repo, userID, state.Request.CanvasID, call) + case "director_preview": + return cloudAgentDirectorPreview(repo, userID, state.Request.CanvasID, call) + case "canvas_list_node_types": + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &struct{}{}); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + return cloudAgentNodeTypes(), nil + case "canvas_get_state": + var args struct { + Offset int `json:"offset"` + ConnectionOffset int `json:"connectionOffset"` + NodeIDs []string `json:"nodeIds"` + FocusNodeIDs []string `json:"focusNodeIds"` + Depth *int `json:"depth"` + IncludeRelated *bool `json:"includeRelated"` + StoryboardOffset int `json:"storyboardOffset"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + depth := 0 + if args.Depth != nil { + depth = *args.Depth + } + if len(args.FocusNodeIDs) > 0 && args.Depth == nil { + depth = 1 + } + if args.Offset < 0 || args.ConnectionOffset < 0 || args.StoryboardOffset < 0 { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:offset、connectionOffset、storyboardOffset 必须是非负整数")} + } + if len(args.NodeIDs) > 8 { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:nodeIds 最多包含8个节点ID")} + } + if len(args.FocusNodeIDs) > 8 { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:focusNodeIds 最多包含8个节点ID")} + } + if len(args.NodeIDs) > 0 && len(args.FocusNodeIDs) > 0 { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:nodeIds 与 focusNodeIds 互斥")} + } + if depth < 0 || depth > 3 { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:depth 必须为0到3")} + } + if len(args.FocusNodeIDs) == 0 && args.Depth != nil { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:depth 只能与 focusNodeIds 一起使用")} + } + includeRelated := args.IncludeRelated != nil && *args.IncludeRelated + if includeRelated && len(args.FocusNodeIDs) == 0 { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:includeRelated 只能与 focusNodeIds 一起使用")} + } + if includeRelated && args.Depth != nil { + return nil, &cloudAgentArgumentError{BadAuthRequest("画布读取参数无效:includeRelated 与 depth 不能同时使用")} + } + canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) + if err != nil { + return nil, err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return nil, err + } + if len(args.FocusNodeIDs) > 0 { + if includeRelated { + return cloudAgentCanvasStateWithRelated(repo, userID, state.Request.CanvasID, doc, args.Offset, args.FocusNodeIDs, args.StoryboardOffset, args.ConnectionOffset) + } + return cloudAgentCanvasStateWithFocus(repo, userID, state.Request.CanvasID, doc, args.Offset, args.FocusNodeIDs, depth, args.StoryboardOffset, args.ConnectionOffset) + } + return cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, args.Offset, args.NodeIDs, args.StoryboardOffset, args.ConnectionOffset) + case "canvas_read_storyboard": + var args struct { + NodeID string `json:"nodeId"` + Offset int `json:"offset"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + if err := validateCloudAgentID(args.NodeID, "分镜节点ID", 80); err != nil || args.Offset < 0 { + return nil, BadAuthRequest("分镜节点ID或分页参数无效") + } + canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) + if err != nil { + return nil, err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return nil, err + } + if _, _, _, err := storyboardNodeFromDocument(doc, args.NodeID); err != nil { + return nil, err + } + view, err := cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, 0, []string{args.NodeID}, args.Offset) + if err != nil { + return nil, err + } + return cloudAgentStoryboardReadResult(view, args.NodeID, cloudAgentNodeHash(doc, args.NodeID)) + case "canvas_read_batch_table": + var args struct { + NodeID string `json:"nodeId"` + Offset int `json:"offset"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + if err := validateCloudAgentID(args.NodeID, "批量创作表节点ID", 80); err != nil || args.Offset < 0 { + return nil, BadAuthRequest("批量创作表节点ID或分页参数无效") + } + canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) + if err != nil { + return nil, err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return nil, err + } + if _, _, _, _, err := batchTableNodeFromDocument(doc, args.NodeID); err != nil { + return nil, err + } + view, err := cloudAgentCanvasState(repo, userID, state.Request.CanvasID, doc, 0, []string{args.NodeID}, args.Offset) + if err != nil { + return nil, err + } + return cloudAgentBatchTableReadResult(view, args.NodeID, cloudAgentNodeHash(doc, args.NodeID)) + case "image_text_detect": + var args struct { + NodeID string `json:"nodeId"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + if err := validateCloudAgentID(args.NodeID, "图片节点ID", 80); err != nil { + return nil, err + } + canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) + if err != nil { + return nil, err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return nil, err + } + nodes, err := creationObjects(doc["nodes"]) + if err != nil { + return nil, err + } + node := nodes[args.NodeID] + if node == nil || stringValue(node["type"]) != "image" { + return nil, BadAuthRequest("目标节点不是图片节点") + } + ref, _, err := cloudAgentReference(repo, userID, node) + if err != nil { + return nil, err + } + return map[string]any{"nodeId": args.NodeID, "reference": ref, "status": "ready_for_visual_detection", "outputSchema": []string{"original", "text", "location"}, "nextStep": "使用视觉模型对该参考图返回 JSON 数组;不要把识别结果写回画布"}, nil + case "image_annotation_render": + if len(services) == 0 || services[0] == nil { + return nil, BadAuthRequest("标注资源存储不可用") + } + return cloudAgentRenderImageAnnotations(repo, userID, state, call, services[0]) + case "skill_search": + var args struct { + Keyword string `json:"keyword"` + Limit int `json:"limit"` + // 容忍模型顺手带上的 skillId(对齐 skill_read_file 的参数习惯): + // 检索范围恒为本轮已启用技能,该字段仅接收不生效。 + SkillID string `json:"skillId,omitempty"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + return cloudAgentSearchSkills(state.Skills, args.Keyword, args.Limit) + case "skill_read_file": + var args struct { + SkillID string `json:"skillId"` + Path string `json:"path"` + Offset int `json:"offset"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + if args.Offset < 0 || (args.Path == "" && args.Offset != 0) { + return nil, BadAuthRequest("技能读取偏移无效") + } + for _, skill := range state.Skills { + if skill.ID == args.SkillID { + if args.Path == "" { + return map[string]any{"version": skill.Version, "entryPath": cloudAgentSkillEntryPath, "files": cloudAgentSkillPaths(skill), "guidance": "先读取 SKILL.md,再只读取入口明确引用且当前任务需要的参考文件。只能读取 files 中列出的路径;不要重复列目录或猜测路径"}, nil + } + if service != nil { + detail, err := service.SkillDetail(userID, skill.ID) + if err != nil { + return nil, err + } + if !detail.IsAdded || detail.Status != 1 || detail.VersionID != skill.Version || detail.ContentHash != skill.Hash { + return nil, creationConflict("技能已更新或不可用,请重试") + } + if args.Path == cloudAgentSkillEntryPath { + return cloudAgentSkillPage(skill.Version, args.Path, detail.Instruction, args.Offset) + } + if _, ok := skill.Files[args.Path]; !ok { + return nil, BadAuthRequest("参考文件未包含在本轮固定快照中") + } + file, err := service.SkillPackageFile(userID, skill.ID, args.Path) + if err != nil { + return nil, err + } + if file.Binary { + return nil, BadAuthRequest("不支持读取二进制技能文件") + } + latest, err := service.SkillDetail(userID, skill.ID) + if err != nil { + return nil, err + } + if !latest.IsAdded || latest.Status != 1 || latest.VersionID != skill.Version || latest.ContentHash != skill.Hash { + return nil, creationConflict("技能已更新或不可用,请重试") + } + return cloudAgentSkillPage(skill.Version, args.Path, file.Content, args.Offset) + } + if args.Path == cloudAgentSkillEntryPath && strings.TrimSpace(skill.Instruction) != "" { + return map[string]any{"version": skill.Version, "path": args.Path, "content": skill.Instruction}, nil + } + if content, ok := skill.Files[args.Path]; ok && content != "" { + return map[string]any{"version": skill.Version, "path": args.Path, "content": content}, nil + } + return nil, BadAuthRequest(fmt.Sprintf("参考文件未包含在本轮固定快照中;可读路径:%s。不要重试此路径", strings.Join(cloudAgentSkillPaths(skill), ", "))) + } + } + return nil, BadAuthRequest("技能未在本轮启用,或参考文件未包含在固定快照中") + case "task_get": + var args struct { + TaskID string `json:"taskId"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + task, err := repo.TaskForUser(userID, args.TaskID) + if err != nil { + return nil, err + } + if task.ProjectID != state.Request.CanvasID { + return nil, BadAuthRequest("不能读取其他画布的任务") + } + result := cloudAgentTaskDiagnostic(repo, task) + result["status"] = task.Status + result["text"] = truncateRunes(taskResultText(task.ResultJSON), 4000) + return result, nil + } + return nil, BadAuthRequest("未知工具") +} + +func cloudAgentRenderImageAnnotations(repo *repository.Repository, userID string, state *cloudAgentRuntime, call cloudAgentCall, service *Service) (any, error) { + var args struct { + NodeID string `json:"nodeId"` + Annotations []struct { + Label string `json:"label"` + X float64 `json:"x"` + Y float64 `json:"y"` + } `json:"annotations"` + } + if err := decodeCloudAgentJSONObject(call.Function.Arguments, &args); err != nil { + return nil, cloudAgentJSONArgumentError(err) + } + if err := validateCloudAgentID(args.NodeID, "图片节点ID", 80); err != nil { + return nil, err + } + if len(args.Annotations) == 0 || len(args.Annotations) > 30 { + return nil, BadAuthRequest("标注数量必须在1到30之间") + } + canvas, err := repo.CanvasProjectForUser(userID, state.Request.CanvasID) + if err != nil { + return nil, err + } + doc, err := creationDocument(canvas.PayloadJSON) + if err != nil { + return nil, err + } + nodes, err := creationObjects(doc["nodes"]) + if err != nil { + return nil, err + } + node := nodes[args.NodeID] + if node == nil || stringValue(node["type"]) != "image" { + return nil, BadAuthRequest("目标节点不是图片节点") + } + width, height := 1024.0, 1024.0 + if v, ok := node["width"].(float64); ok && v > 0 { + width = v + } + if v, ok := node["height"].(float64); ok && v > 0 { + height = v + } + if width < 1 || height < 1 || width > 8192 || height > 8192 || width*height > 16_777_216 { + return nil, BadAuthRequest("标注图片尺寸超过 1600 万像素预算,请先缩小图片节点") + } + if state.RuntimeRunID == "" { + return nil, BadAuthRequest("标注缺少运行归属") + } + canvasImage := image.NewRGBA(image.Rect(0, 0, int(width), int(height))) + red := color.RGBA{R: 239, G: 68, B: 68, A: 255} + white := color.RGBA{R: 255, G: 255, B: 255, A: 255} + for _, item := range args.Annotations { + if item.X < 0 || item.X > 1 || item.Y < 0 || item.Y > 1 || strings.TrimSpace(item.Label) == "" { + return nil, BadAuthRequest("标注坐标必须在0到1之间且文字不能为空") + } + x, y := int(item.X*width), int(item.Y*height) + drawFilledCircle(canvasImage, x, y, 18, red) + // A white center keeps markers visually distinct on dark and light images. + drawFilledCircle(canvasImage, x, y, 8, white) + } + var encoded bytes.Buffer + if err := png.Encode(&encoded, canvasImage); err != nil { + return nil, fmt.Errorf("编码标注参考图失败: %w", err) + } + if state.TransientReferences == nil { + state.TransientReferences = map[string]cloudAgentTransientReference{} + } + refID := "annotation-" + call.ID + identity := "agent-annotation:" + state.RuntimeRunID + ":" + call.ID + ":" + creationHash(call.Function.Arguments) + resource, err := service.UploadResourceFile(userID, "annotation-overlay.png", int64(encoded.Len()), "image", int(width), int(height), 0, bytes.NewReader(encoded.Bytes()), identity) + if err != nil { + return nil, err + } + expiresAt := time.Now().Add(24 * time.Hour) + if err := repo.UpsertCloudAgentResourceLeases(userID, state.RuntimeRunID, "annotation:"+call.ID, []string{resource.ID}, expiresAt); err != nil { + return nil, err + } + state.TransientReferences[refID] = cloudAgentTransientReference{ID: refID, Name: "annotation-overlay.png", MIMEType: "image/png", ResourceID: resource.ID, ExpiresAt: expiresAt} + return map[string]any{"nodeId": args.NodeID, "width": width, "height": height, "annotationCount": len(args.Annotations), "referenceTransientId": refID, "mimeType": "image/png", "referenceOrder": []string{args.NodeID, refID}, "expiresAt": expiresAt, "persisted": true}, nil +} + +func drawFilledCircle(dst draw.Image, cx, cy, radius int, fill color.Color) { + for y := cy - radius; y <= cy+radius; y++ { + for x := cx - radius; x <= cx+radius; x++ { + dx, dy := x-cx, y-cy + if dx*dx+dy*dy <= radius*radius { + dst.Set(x, y, fill) + } + } + } +} diff --git a/backend/internal/app/cloud_agent_tools_skills.go b/backend/internal/app/cloud_agent_tools_skills.go new file mode 100644 index 000000000..b45f0dcb1 --- /dev/null +++ b/backend/internal/app/cloud_agent_tools_skills.go @@ -0,0 +1,338 @@ +// 画布 Agent 的技能(Skills)检索:按关键词在已启用技能的正文与方法卡中打分、截取片段。 +// +// 检索完全在内存中进行,不调用模型;结果按预算裁剪,避免把整份技能正文塞进上下文。 +// +// cloudAgentSkillSearch* 实现 Agent 侧的技能检索:与 recall_lessons 的记忆检索同构 +// (同一套分词器与三档加权),数据源为本轮冻结的技能快照——搜到的必然是能读的。 +// 只返回「哪张卡值得读 + 路径」,正文仍走 skill_read_file 的渐进披露,技能内容永不整体内联。 + +package app + +import ( + "sort" + "strings" + "unicode" + "unicode/utf8" +) + +type cloudAgentSkill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Version string `json:"version"` + Hash string `json:"hash"` + Instruction string `json:"instruction,omitempty"` + Files map[string]string `json:"files,omitempty"` +} + +const cloudAgentSkillEntryPath = "SKILL.md" + +func cloudAgentSkillPaths(skill cloudAgentSkill) []string { + paths := make([]string, 0, len(skill.Files)+1) + seen := make(map[string]struct{}, len(skill.Files)+1) + if strings.TrimSpace(skill.Instruction) != "" { + paths = append(paths, cloudAgentSkillEntryPath) + seen[cloudAgentSkillEntryPath] = struct{}{} + } + for path := range skill.Files { + if _, exists := seen[path]; exists { + continue + } + paths = append(paths, path) + seen[path] = struct{}{} + } + sort.Strings(paths) + return paths +} + +const ( + cloudAgentSkillSearchTokenMax = 8 + cloudAgentSkillSearchDefault = 8 + cloudAgentSkillSearchMax = 20 + cloudAgentSkillSnippetRunes = 120 + // 一次检索最多下发多少条卡路径(所有命中条目共享预算),防止大包把上下文撑爆。 + cloudAgentSkillSearchCardBudget = 40 +) + +func cloudAgentSkillSearchTokens(keyword string) []string { + tokens := make([]string, 0, cloudAgentSkillSearchTokenMax) + seen := make(map[string]bool, cloudAgentSkillSearchTokenMax) + for _, raw := range strings.FieldsFunc(keyword, cloudAgentSkillTokenSeparator) { + token := strings.ToLower(strings.TrimSpace(raw)) + // 单字停用词过滤照拿的是英文逻辑(a / I);汉字单字「梗」「钩」「戏」本身是完整 + // 语义的最小单位。一刀切丢后 tokens 为空,检索会退化成「列全部已启用技能索引」。 + // 这里只放行单个汉字的 token,英文/数字单字与空串仍按停用词丢掉。 + first, size := utf8.DecodeRuneInString(token) + singleHan := size == len(token) && unicode.Is(unicode.Han, first) + if (!singleHan && utf8.RuneCountInString(token) < 2) || seen[token] { + continue + } + seen[token] = true + tokens = append(tokens, token) + if len(tokens) >= cloudAgentSkillSearchTokenMax { + break + } + } + return tokens +} + +func cloudAgentSkillTokenSeparator(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune(",,、。;;::/\\|()()[]【】{}<>\"'“”‘’!!??+*&", r) +} + +// cloudAgentSkillCardSlug 取卡路径的文件名(去目录与扩展名),用于关键词匹配。 +// 例:cards/czks-hook-paywall.md → czks-hook-paywall +func cloudAgentSkillCardSlug(path string) string { + base := path + if at := strings.LastIndex(base, "/"); at >= 0 { + base = base[at+1:] + } + for _, ext := range []string{".md", ".txt", ".json"} { + if strings.HasSuffix(base, ext) { + base = strings.TrimSuffix(base, ext) + break + } + } + return strings.ToLower(base) +} + +// cloudAgentSkillCardPaths 返回技能包内除入口以外的全部卡路径(有序)。 +// 这些路径在快照里本来就有(Files 已载入),下发索引不需要读取任何正文。 +func cloudAgentSkillCardPaths(skill cloudAgentSkill) []string { + paths := make([]string, 0, len(skill.Files)) + for path := range skill.Files { + if path == cloudAgentSkillEntryPath { + continue + } + paths = append(paths, path) + } + sort.Strings(paths) + return paths +} + +// cloudAgentSkillMatch 同时给技能与其卡片打分,返回总分与最具体的命中路径。 +// 卡片命中 4 分 > 技能名 3 分 > 描述 2 分:命中最具体的那一层,Agent 才不必先读总纲。 +func cloudAgentSkillMatch(skill cloudAgentSkill, tokens []string) (int, string) { + if len(tokens) == 0 { + return 0, cloudAgentSkillEntryPath + } + name := strings.ToLower(skill.Name) + description := strings.ToLower(skill.Description) + score := 0 + for _, token := range tokens { + switch { + case strings.Contains(name, token): + score += 3 + case strings.Contains(description, token): + score += 2 + } + } + cards := cloudAgentSkillCardPaths(skill) + bestCard := "" + cardScore := 0 + for _, path := range cards { + slug := cloudAgentSkillCardSlug(path) + hit := 0 + for _, token := range tokens { + if strings.Contains(slug, token) { + hit += 4 + } + } + if hit > cardScore { + cardScore = hit + bestCard = path + } + } + score += cardScore + if bestCard != "" { + return score, bestCard + } + return score, cloudAgentSkillEntryPath +} + +// cloudAgentSkillRuneIndex 在 rune 序列里做朴素子串查找,返回 rune 下标(未命中 -1)。 +// 描述只有数百字、token 最多 8 个,朴素查找足够,且避免字节/rune 下标混用。 +func cloudAgentSkillRuneIndex(hay, needle []rune) int { + if len(needle) == 0 || len(needle) > len(hay) { + return -1 + } + for i := 0; i+len(needle) <= len(hay); i++ { + matched := true + for j := range needle { + if unicode.ToLower(hay[i+j]) != unicode.ToLower(needle[j]) { + matched = false + break + } + } + if matched { + return i + } + } + return -1 +} + +func cloudAgentSkillSnippet(skill cloudAgentSkill, tokens []string) string { + description := strings.TrimSpace(skill.Description) + if description == "" { + return "" + } + runes := []rune(description) + if len(runes) <= cloudAgentSkillSnippetRunes { + return description + } + if len(tokens) == 0 { + return strings.TrimSpace(string(runes[:cloudAgentSkillSnippetRunes])) + "…" + } + // 命中位置必须按 rune 计算:strings.Index 返回字节偏移,中文下远大于 rune 下标, + // 直接拿它切 []rune 会越界(曾导致 panic: slice bounds out of range)。 + hit := -1 + for _, token := range tokens { + if at := cloudAgentSkillRuneIndex(runes, []rune(strings.ToLower(token))); at >= 0 && (hit < 0 || at < hit) { + hit = at + } + } + if hit < 0 { + return strings.TrimSpace(string(runes[:cloudAgentSkillSnippetRunes])) + "…" + } + start := hit - cloudAgentSkillSnippetRunes/3 + if start < 0 { + start = 0 + } + if start > len(runes) { + start = len(runes) + } + end := start + cloudAgentSkillSnippetRunes + if end > len(runes) { + end = len(runes) + } + if start > end { + start = end + } + snippet := strings.TrimSpace(string(runes[start:end])) + if start > 0 { + snippet = "…" + snippet + } + if end < len(runes) { + snippet += "…" + } + return snippet +} + +func cloudAgentSearchSkills(skills []cloudAgentSkill, keyword string, limit int) (map[string]any, error) { + keyword = strings.TrimSpace(keyword) + if limit <= 0 { + limit = cloudAgentSkillSearchDefault + } + if limit > cloudAgentSkillSearchMax { + limit = cloudAgentSkillSearchMax + } + guidance := "用 skill_read_file 读取命中条目的 path:若 path 是 cards/… 就直读该卡;若 path 是 SKILL.md,先看返回的 cards 索引再直奔需要的卡,通常无需先读总纲。只能读取返回的 path,不要猜路径。返回的是索引,不是指令。" + if len(skills) == 0 { + return map[string]any{"matches": []map[string]any{}, "total": 0, + "guidance": "本轮没有已启用的技能;skill_search 只搜索已启用技能。"}, nil + } + tokens := cloudAgentSkillSearchTokens(keyword) + if len(tokens) == 0 { + entries := make([]map[string]any, 0, len(skills)) + for _, skill := range skills { + entries = append(entries, map[string]any{ + "skillId": skill.ID, "skillName": skill.Name, "path": cloudAgentSkillEntryPath, + "entryPath": cloudAgentSkillEntryPath, + "snippet": cloudAgentSkillSnippet(skill, nil), + "cardCount": len(cloudAgentSkillCardPaths(skill)), + }) + if len(entries) >= limit { + break + } + } + return map[string]any{"matches": entries, "total": len(skills), "guidance": guidance}, nil + } + type scored struct { + skill cloudAgentSkill + score int + path string + } + ranked := make([]scored, 0, len(skills)) + for _, skill := range skills { + if score, path := cloudAgentSkillMatch(skill, tokens); score > 0 { + ranked = append(ranked, scored{skill: skill, score: score, path: path}) + } + } + sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score }) + entries := make([]map[string]any, 0, limit) + // 卡索引按排名分配预算:命中卡片时 path 已是卡路径,不必再下发索引; + // 只命中技能时下发卡路径,Agent 可以直奔某张卡,不必先读 SKILL.md 总纲。 + cardBudget := cloudAgentSkillSearchCardBudget + for _, entry := range ranked { + if len(entries) >= limit { + break + } + cards := cloudAgentSkillCardPaths(entry.skill) + item := map[string]any{ + "skillId": entry.skill.ID, + "skillName": entry.skill.Name, + "path": entry.path, + "entryPath": cloudAgentSkillEntryPath, + "score": entry.score, + "snippet": cloudAgentSkillSnippet(entry.skill, tokens), + "cardCount": len(cards), + } + if entry.path == cloudAgentSkillEntryPath && len(cards) > 0 && cardBudget > 0 { + shown := cards + if len(shown) > cardBudget { + shown = shown[:cardBudget] + } + cardBudget -= len(shown) + item["cards"] = shown + } + entries = append(entries, item) + } + if len(entries) == 0 { + return map[string]any{"matches": []map[string]any{}, "total": 0, + "guidance": "没有命中「" + keyword + "」的已启用技能。换个说法重试,或先用 skill_search 不带参数列出已启用技能索引,再用 skill_read_file 读取其中的 SKILL.md 与卡。"}, nil + } + return map[string]any{"matches": entries, "total": len(entries), "keyword": keyword, "guidance": guidance}, nil +} + +func (s *Service) cloudAgentSkills(userID string, ids []string) ([]cloudAgentSkill, error) { + snapshots := []cloudAgentSkill{} + for _, id := range ids { + skill, err := s.SkillDetail(userID, id) + if err != nil { + return nil, err + } + if !skill.IsAdded || skill.Status != 1 { + return nil, BadAuthRequest("只能使用用户技能库中已安装且启用的技能") + } + // Skill content is loaded only after the model explicitly calls + // skill_read_file; keep the run context to stable metadata and paths. + // The description is public metadata (market listing) and lets the + // model route between activated skills without reading any body. + snapshot := cloudAgentSkill{ID: id, Name: skill.SkillName, Description: skill.Description, Version: skill.VersionID, Hash: skill.ContentHash, Files: map[string]string{cloudAgentSkillEntryPath: ""}} + files, err := s.SkillPackageFiles(userID, id) + if err != nil { + return nil, err + } + for _, file := range files { + // The entry is listed separately; file bodies are fetched on demand. + if file.Path == cloudAgentSkillEntryPath { + continue + } + // Executable/binary packages are never executed; text references are data only. + if !strings.HasSuffix(file.Path, ".md") && !strings.HasSuffix(file.Path, ".txt") && !strings.HasSuffix(file.Path, ".json") { + continue + } + snapshot.Files[file.Path] = "" + } + // Detect an update during package reads instead of mixing two versions. + latest, err := s.SkillDetail(userID, id) + if err != nil { + return nil, err + } + if latest.VersionID != skill.VersionID || latest.ContentHash != skill.ContentHash { + return nil, creationConflict("技能在读取时已更新,请重试") + } + snapshots = append(snapshots, snapshot) + } + return snapshots, nil +} diff --git a/backend/internal/app/logical_models.go b/backend/internal/app/logical_models.go index 8665a2353..fbce5c57f 100644 --- a/backend/internal/app/logical_models.go +++ b/backend/internal/app/logical_models.go @@ -4,16 +4,13 @@ import ( "encoding/json" "errors" "fmt" - "math" "regexp" - "sort" "strings" "time" + "gorm.io/gorm" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/repository" - - "gorm.io/gorm" ) var logicalModelCodePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,79}$`) @@ -41,6 +38,7 @@ type LogicalModelRequest struct { // SourceChannelModelID 仅供系统渠道同步流程使用,前台模型不再拥有独立的能力和价格真相。 SourceChannelModelID string `json:"-"` } + type LogicalRouteRequest struct { ChannelModelID string `json:"channelModelId"` Enabled bool `json:"enabled"` @@ -130,236 +128,6 @@ type RouteSimulationResult struct { Candidates []RouteSimulationCandidate `json:"candidates"` } -func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogicalModel, error) { - snapshot, err := s.routeCatalogSnapshot() - if err != nil { - return nil, err - } - result := make([]PublicLogicalModel, 0, len(snapshot.Ordered)) - for _, id := range snapshot.Ordered { - cached := snapshot.Models[id] - structuralSpecs := availableCachedRouteSpecs(cached.Routes) - coverageValid := logicalModelCapabilityCovered(cached.ProductSpec, structuralSpecs) - available := coverageValid && hasHealthyCachedRoute(s, cached.Routes) - if intent != nil { - resolvedIntent := *intent - resolvedIntent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) - productMatch := MatchCapability(cached.ProductSpec, resolvedIntent) - if !productMatch.Matched { - continue - } - available = false - if coverageValid { - for _, route := range cached.Routes { - if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && (cached.Model.PricePolicy != "channel" || channelModelPriceTierForIntent(route.ChannelModel, resolvedIntent) != nil) { - available = true - break - } - } - } - } - result = append(result, publicLogicalModel(cached, available)) - } - return result, nil -} - -func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel { - item := cached.Model - productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, enabledLogicalRouteSpecs(cached.Routes)) - profiles := make([]CapabilitySpec, 0, len(cached.Routes)) - seen := make(map[string]bool, len(cached.Routes)) - for _, route := range cached.Routes { - if !route.Route.Enabled || route.Route.Weight <= 0 { - continue - } - key := capabilityFingerprint(route.CapabilitySpec) - if !seen[key] { - seen[key] = true - profiles = append(profiles, route.CapabilitySpec) - } - } - - priceTiers := publicLogicalModelPriceTiers(cached) - pricingMode, displayPrice, priceLabel := computeModelPriceDisplay(item, priceTiers) - - return PublicLogicalModel{ - ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, - Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, - PricePolicy: item.PricePolicy, PricingMode: pricingMode, DisplayPrice: displayPrice, - PriceLabel: priceLabel, BillingMode: item.BillingMode, - UnitPriceMicrocredits: item.UnitPriceMicrocredits, - InputPriceMicrocredits: item.InputPriceMicrocredits, - OutputPriceMicrocredits: item.OutputPriceMicrocredits, - CachedPriceMicrocredits: item.CachedPriceMicrocredits, - PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), - CapabilitySpec: productSpec, CapabilityProfiles: profiles, - DefaultOptions: cached.Defaults, Available: available, - } -} - -func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier { - if cached.Model.PricePolicy != "channel" { - return []PublicLogicalModelPriceTier{} - } - result := make([]PublicLogicalModelPriceTier, 0) - seen := make(map[string]bool) - for _, route := range cached.Routes { - if !route.Route.Enabled || route.Route.Weight <= 0 { - continue - } - for _, tier := range route.ChannelModel.PriceTiers { - if !tier.Enabled || !tier.PriceConfigured { - continue - } - selector := skuSelectorForTier(tier) - _, selectorKey, selectorErr := model.CanonicalSKUSelector(selector) - if selectorErr != nil { - continue - } - key := fmt.Sprintf("%s:%s:%d:%d:%d:%d", selectorKey, tier.BillingMode, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) - if seen[key] { - continue - } - seen[key] = true - result = append(result, PublicLogicalModelPriceTier{Selector: selector, Resolution: normalizeChannelModelTierResolution(tier.Resolution), VideoSeconds: tier.VideoSeconds, BillingMode: tier.BillingMode, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits}) - } - } - return result -} - -func decodeLegacyModelIDs(raw string) []string { - var values []string - if err := json.Unmarshal([]byte(raw), &values); err != nil { - return []string{} - } - return normalizeLegacyModelIDs(values) -} - -func normalizeLegacyModelIDs(values []string) []string { - result := make([]string, 0, len(values)) - seen := make(map[string]bool, len(values)) - for _, raw := range values { - value := strings.TrimSpace(raw) - if value == "" || seen[value] { - continue - } - seen[value] = true - result = append(result, value) - } - return result -} - -func enabledLogicalRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec { - specs := make([]CapabilitySpec, 0, len(routes)) - for _, route := range routes { - if !route.Route.Enabled || route.Route.Weight <= 0 { - continue - } - specs = append(specs, route.CapabilitySpec) - } - return specs -} - -// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored -// only `*` for a custom size. The wildcard remains for matching custom values, -// while route presets are restored for admin and creator-side selectors. -// Callers must pass only currently enabled routes; disabled or zero-weight -// routes would otherwise advertise size tiers the public catalog cannot route. -func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec { - result := spec - result.Options = make(map[string]OptionConstraint, len(spec.Options)) - for name, constraint := range spec.Options { - if !isWildcardOptionConstraint(constraint) { - result.Options[name] = constraint - continue - } - values := append([]any(nil), constraint.Values...) - seen := make(map[string]bool, len(values)) - for _, value := range values { - seen[normalizedScalar(value)] = true - } - for _, route := range routes { - for _, value := range route.Options[name].Values { - key := normalizedScalar(value) - if key != "" && !seen[key] { - seen[key] = true - values = append(values, value) - } - } - } - result.Options[name] = OptionConstraint{Values: values} - } - // 前台规格里的预设可能只来自单条线路的快照(后续新增的供应线路还没同步进来), - // 因此不管自身是否已有预设都要与各线路取并集,否则多档会被压成单档。 - if merged := mergeCapabilityImageSize(append([]CapabilitySpec{spec}, routes...)); merged != nil { - if result.ImageSize == nil { - result.ImageSize = merged - } else { - restored := *result.ImageSize - if restored.Parameter == "" { - restored.Parameter = merged.Parameter - } - if !restored.AllowCustom { - restored.AllowCustom = merged.AllowCustom - } - restored.Presets = merged.Presets - result.ImageSize = &restored - } - } - return result -} - -func mergeCapabilityImageSize(specs []CapabilitySpec) *CapabilityImageSize { - var result *CapabilityImageSize - seen := map[string]bool{} - for _, spec := range specs { - part := spec.ImageSize - if part == nil { - continue - } - if result == nil { - result = &CapabilityImageSize{Parameter: part.Parameter, AllowCustom: part.AllowCustom} - } else { - if result.Parameter != "aspect_ratio" && part.Parameter == "aspect_ratio" { - result.Parameter = "aspect_ratio" - } else if result.Parameter == "" { - result.Parameter = part.Parameter - } - result.AllowCustom = result.AllowCustom || part.AllowCustom - } - for _, preset := range part.Presets { - key := preset.Tier + ":" + preset.Ratio + ":" + preset.Size - if key == "::" || seen[key] { - continue - } - seen[key] = true - result.Presets = append(result.Presets, preset) - } - } - return result -} - -// capabilityFingerprint 用规范化后的结构去重能力画像;不能直接依赖原始 JSON, -// 因为同一组枚举能力的数组顺序不应造成重复展示。 -func capabilityFingerprint(spec CapabilitySpec) string { - copySpec := spec - copySpec.Operations = append([]string(nil), spec.Operations...) - sort.Strings(copySpec.Operations) - copySpec.Inputs = make(map[string]InputConstraint, len(spec.Inputs)) - for name, constraint := range spec.Inputs { - copySpec.Inputs[name] = constraint - } - copySpec.Options = make(map[string]OptionConstraint, len(spec.Options)) - for name, constraint := range spec.Options { - values := append([]any(nil), constraint.Values...) - sort.SliceStable(values, func(i, j int) bool { return normalizedScalar(values[i]) < normalizedScalar(values[j]) }) - constraint.Values = values - copySpec.Options[name] = constraint - } - encoded, _ := json.Marshal(copySpec) - return string(encoded) -} - func (s *Service) AdminLogicalModels(actor *model.User) ([]AdminLogicalModel, error) { if err := s.RequireAdmin(actor); err != nil { return nil, err @@ -966,198 +734,6 @@ func (s *Service) SimulateLogicalModelRoute(actor *model.User, id string, intent func logicalModelNotFound(err error) bool { return errors.Is(err, gorm.ErrRecordNotFound) } -// 前台模型只声明供应线路真实提供的总目录;组合是否可承接仍由匿名 capabilityProfiles 按 OR 语义判断。 -func validateProductSpecWithinRoutes(product CapabilitySpec, routeSpecs []CapabilitySpec) error { - for _, routeSpec := range routeSpecs { - if normalizeCapability(routeSpec.Capability) != normalizeCapability(product.Capability) { - return BadAuthRequest("供应线路能力类型与前台模型不一致") - } - } - if len(product.Operations) == 0 { - unrestricted := false - for _, routeSpec := range routeSpecs { - if len(routeSpec.Operations) == 0 { - unrestricted = true - break - } - } - if !unrestricted { - return BadAuthRequest("创作端生成方式必须从供应线路支持的选项中选择") - } - } else { - for _, operation := range product.Operations { - supported := false - for _, routeSpec := range routeSpecs { - if len(routeSpec.Operations) == 0 || containsCapabilityString(routeSpec.Operations, operation) { - supported = true - break - } - } - if !supported { - return BadAuthRequest("创作端生成方式不受任何供应线路支持:" + operation) - } - } - } - for name, constraint := range product.Inputs { - if !inputConstraintCovered(constraint, name, routeSpecs) { - return BadAuthRequest("创作端输入范围超出供应线路能力:" + name) - } - } - for name, constraint := range product.Options { - if !optionConstraintCovered(constraint, name, routeSpecs) { - return BadAuthRequest("创作端参数超出供应线路能力:" + name) - } - } - return nil -} - -func inputConstraintCovered(candidate InputConstraint, name string, routeSpecs []CapabilitySpec) bool { - next := candidate.Min - for next <= candidate.Max { - coveredUntil := next - 1 - for _, routeSpec := range routeSpecs { - constraint, exists := routeSpec.Inputs[name] - if !exists { - constraint = InputConstraint{Min: 0, Max: 0} - } - if constraint.Min <= next && constraint.Max >= next && constraint.Max > coveredUntil { - coveredUntil = constraint.Max - } - } - if coveredUntil < next { - return false - } - next = coveredUntil + 1 - } - return true -} - -func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs []CapabilitySpec) bool { - routeConstraints := make([]OptionConstraint, 0, len(routeSpecs)) - for _, routeSpec := range routeSpecs { - if constraint, exists := routeSpec.Options[name]; exists { - routeConstraints = append(routeConstraints, constraint) - } - } - if len(routeConstraints) == 0 { - return false - } - for _, routeConstraint := range routeConstraints { - if isWildcardOptionConstraint(routeConstraint) { - return true - } - } - if len(candidate.Values) > 0 { - for _, value := range candidate.Values { - if !optionValueSupported(name, value, routeConstraints) { - return false - } - } - return true - } - if candidate.Min == nil || candidate.Max == nil { - return false - } - if math.Abs(*candidate.Max-*candidate.Min) < 1e-9 { - return optionValueSupported(name, *candidate.Min, routeConstraints) - } - if candidate.Step == nil { - return continuousOptionRangeCovered(*candidate.Min, *candidate.Max, routeConstraints) - } - step := *candidate.Step - count := int(math.Floor((*candidate.Max-*candidate.Min)/step+1e-9)) + 1 - if count <= 10000 { - for index := 0; index < count; index++ { - value := *candidate.Min + float64(index)*step - if !optionValueSupported(name, value, routeConstraints) { - return false - } - } - return true - } - // 超大离散范围不逐点展开;只有单条连续范围或步长完全兼容的线路才能作为可靠来源。 - for _, routeConstraint := range routeConstraints { - if routeConstraint.Min == nil || routeConstraint.Max == nil || *routeConstraint.Min > *candidate.Min || *routeConstraint.Max < *candidate.Max { - continue - } - if routeConstraint.Step == nil { - return true - } - startSteps := (*candidate.Min - *routeConstraint.Min) / *routeConstraint.Step - stepRatio := step / *routeConstraint.Step - if math.Abs(startSteps-math.Round(startSteps)) < 1e-9 && math.Abs(stepRatio-math.Round(stepRatio)) < 1e-9 { - return true - } - } - return false -} - -func optionValueSupported(name string, value any, constraints []OptionConstraint) bool { - for _, constraint := range constraints { - if isWildcardOptionConstraint(constraint) { - return true - } - if matchOptionConstraint(name, constraint, value) { - return true - } - } - return false -} - -func isWildcardOptionConstraint(constraint OptionConstraint) bool { - for _, value := range constraint.Values { - if normalizedScalar(value) == "*" { - return true - } - } - return false -} - -func continuousOptionRangeCovered(minimum float64, maximum float64, constraints []OptionConstraint) bool { - next := minimum - for next <= maximum+1e-9 { - coveredUntil := next - advanced := false - for _, constraint := range constraints { - if constraint.Min == nil || constraint.Max == nil || constraint.Step != nil { - continue - } - if *constraint.Min <= next+1e-9 && *constraint.Max >= next-1e-9 && *constraint.Max > coveredUntil { - coveredUntil = *constraint.Max - advanced = true - } - } - if coveredUntil >= maximum-1e-9 { - return true - } - if !advanced { - return false - } - next = coveredUntil - } - return true -} - -func anyValues(values []string) OptionConstraint { - result := make([]any, 0, len(values)) - for _, value := range values { - result = append(result, value) - } - return OptionConstraint{Values: result} -} - -func boolValues(supportsTrue bool) OptionConstraint { - values := []any{false} - if supportsTrue { - values = append(values, true) - } - return OptionConstraint{Values: values} -} - -func numericRange(minimum float64, maximum float64, step float64) OptionConstraint { - return OptionConstraint{Min: &minimum, Max: &maximum, Step: &step} -} - // computeModelPriceDisplay 计算模型的价格展示信息 // 返回:pricingMode, displayPrice, priceLabel func computeModelPriceDisplay(model model.LogicalModel, priceTiers []PublicLogicalModelPriceTier) (string, *int64, string) { diff --git a/backend/internal/app/logical_models_catalog.go b/backend/internal/app/logical_models_catalog.go new file mode 100644 index 000000000..9e55b47ed --- /dev/null +++ b/backend/internal/app/logical_models_catalog.go @@ -0,0 +1,242 @@ +// 逻辑模型的前台目录与价格展示:把后台配置的逻辑模型投影成用户可见的列表和价格档。 + +package app + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogicalModel, error) { + snapshot, err := s.routeCatalogSnapshot() + if err != nil { + return nil, err + } + result := make([]PublicLogicalModel, 0, len(snapshot.Ordered)) + for _, id := range snapshot.Ordered { + cached := snapshot.Models[id] + structuralSpecs := availableCachedRouteSpecs(cached.Routes) + coverageValid := logicalModelCapabilityCovered(cached.ProductSpec, structuralSpecs) + available := coverageValid && hasHealthyCachedRoute(s, cached.Routes) + if intent != nil { + resolvedIntent := *intent + resolvedIntent.Options = mergeIntentDefaults(intent.Options, cached.Defaults) + productMatch := MatchCapability(cached.ProductSpec, resolvedIntent) + if !productMatch.Matched { + continue + } + available = false + if coverageValid { + for _, route := range cached.Routes { + if route.Route.Enabled && route.Route.Weight > 0 && !s.logicalRouteBlocked(route) && MatchCapability(route.CapabilitySpec, resolvedIntent).Matched && (cached.Model.PricePolicy != "channel" || channelModelPriceTierForIntent(route.ChannelModel, resolvedIntent) != nil) { + available = true + break + } + } + } + } + result = append(result, publicLogicalModel(cached, available)) + } + return result, nil +} + +func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel { + item := cached.Model + productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, enabledLogicalRouteSpecs(cached.Routes)) + profiles := make([]CapabilitySpec, 0, len(cached.Routes)) + seen := make(map[string]bool, len(cached.Routes)) + for _, route := range cached.Routes { + if !route.Route.Enabled || route.Route.Weight <= 0 { + continue + } + key := capabilityFingerprint(route.CapabilitySpec) + if !seen[key] { + seen[key] = true + profiles = append(profiles, route.CapabilitySpec) + } + } + + priceTiers := publicLogicalModelPriceTiers(cached) + pricingMode, displayPrice, priceLabel := computeModelPriceDisplay(item, priceTiers) + + return PublicLogicalModel{ + ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, + Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, + PricePolicy: item.PricePolicy, PricingMode: pricingMode, DisplayPrice: displayPrice, + PriceLabel: priceLabel, BillingMode: item.BillingMode, + UnitPriceMicrocredits: item.UnitPriceMicrocredits, + InputPriceMicrocredits: item.InputPriceMicrocredits, + OutputPriceMicrocredits: item.OutputPriceMicrocredits, + CachedPriceMicrocredits: item.CachedPriceMicrocredits, + PriceTiers: priceTiers, LegacyModelIDs: decodeLegacyModelIDs(item.LegacyModelIDsJSON), + CapabilitySpec: productSpec, CapabilityProfiles: profiles, + DefaultOptions: cached.Defaults, Available: available, + } +} + +func publicLogicalModelPriceTiers(cached cachedLogicalModel) []PublicLogicalModelPriceTier { + if cached.Model.PricePolicy != "channel" { + return []PublicLogicalModelPriceTier{} + } + result := make([]PublicLogicalModelPriceTier, 0) + seen := make(map[string]bool) + for _, route := range cached.Routes { + if !route.Route.Enabled || route.Route.Weight <= 0 { + continue + } + for _, tier := range route.ChannelModel.PriceTiers { + if !tier.Enabled || !tier.PriceConfigured { + continue + } + selector := skuSelectorForTier(tier) + _, selectorKey, selectorErr := model.CanonicalSKUSelector(selector) + if selectorErr != nil { + continue + } + key := fmt.Sprintf("%s:%s:%d:%d:%d:%d", selectorKey, tier.BillingMode, tier.UnitPriceMicrocredits, tier.InputTokenPriceMicrocredits, tier.OutputTokenPriceMicrocredits, tier.CachedTokenPriceMicrocredits) + if seen[key] { + continue + } + seen[key] = true + result = append(result, PublicLogicalModelPriceTier{Selector: selector, Resolution: normalizeChannelModelTierResolution(tier.Resolution), VideoSeconds: tier.VideoSeconds, BillingMode: tier.BillingMode, UnitPriceMicrocredits: tier.UnitPriceMicrocredits, InputTokenPriceMicrocredits: tier.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: tier.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: tier.CachedTokenPriceMicrocredits}) + } + } + return result +} + +func decodeLegacyModelIDs(raw string) []string { + var values []string + if err := json.Unmarshal([]byte(raw), &values); err != nil { + return []string{} + } + return normalizeLegacyModelIDs(values) +} + +func normalizeLegacyModelIDs(values []string) []string { + result := make([]string, 0, len(values)) + seen := make(map[string]bool, len(values)) + for _, raw := range values { + value := strings.TrimSpace(raw) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func enabledLogicalRouteSpecs(routes []cachedLogicalRoute) []CapabilitySpec { + specs := make([]CapabilitySpec, 0, len(routes)) + for _, route := range routes { + if !route.Route.Enabled || route.Route.Weight <= 0 { + continue + } + specs = append(specs, route.CapabilitySpec) + } + return specs +} + +// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored +// only `*` for a custom size. The wildcard remains for matching custom values, +// while route presets are restored for admin and creator-side selectors. +// Callers must pass only currently enabled routes; disabled or zero-weight +// routes would otherwise advertise size tiers the public catalog cannot route. +func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec { + result := spec + result.Options = make(map[string]OptionConstraint, len(spec.Options)) + for name, constraint := range spec.Options { + if !isWildcardOptionConstraint(constraint) { + result.Options[name] = constraint + continue + } + values := append([]any(nil), constraint.Values...) + seen := make(map[string]bool, len(values)) + for _, value := range values { + seen[normalizedScalar(value)] = true + } + for _, route := range routes { + for _, value := range route.Options[name].Values { + key := normalizedScalar(value) + if key != "" && !seen[key] { + seen[key] = true + values = append(values, value) + } + } + } + result.Options[name] = OptionConstraint{Values: values} + } + // 前台规格里的预设可能只来自单条线路的快照(后续新增的供应线路还没同步进来), + // 因此不管自身是否已有预设都要与各线路取并集,否则多档会被压成单档。 + if merged := mergeCapabilityImageSize(append([]CapabilitySpec{spec}, routes...)); merged != nil { + if result.ImageSize == nil { + result.ImageSize = merged + } else { + restored := *result.ImageSize + if restored.Parameter == "" { + restored.Parameter = merged.Parameter + } + if !restored.AllowCustom { + restored.AllowCustom = merged.AllowCustom + } + restored.Presets = merged.Presets + result.ImageSize = &restored + } + } + return result +} + +func mergeCapabilityImageSize(specs []CapabilitySpec) *CapabilityImageSize { + var result *CapabilityImageSize + seen := map[string]bool{} + for _, spec := range specs { + part := spec.ImageSize + if part == nil { + continue + } + if result == nil { + result = &CapabilityImageSize{Parameter: part.Parameter, AllowCustom: part.AllowCustom} + } else { + if result.Parameter != "aspect_ratio" && part.Parameter == "aspect_ratio" { + result.Parameter = "aspect_ratio" + } else if result.Parameter == "" { + result.Parameter = part.Parameter + } + result.AllowCustom = result.AllowCustom || part.AllowCustom + } + for _, preset := range part.Presets { + key := preset.Tier + ":" + preset.Ratio + ":" + preset.Size + if key == "::" || seen[key] { + continue + } + seen[key] = true + result.Presets = append(result.Presets, preset) + } + } + return result +} + +// capabilityFingerprint 用规范化后的结构去重能力画像;不能直接依赖原始 JSON, +// 因为同一组枚举能力的数组顺序不应造成重复展示。 +func capabilityFingerprint(spec CapabilitySpec) string { + copySpec := spec + copySpec.Operations = append([]string(nil), spec.Operations...) + sort.Strings(copySpec.Operations) + copySpec.Inputs = make(map[string]InputConstraint, len(spec.Inputs)) + for name, constraint := range spec.Inputs { + copySpec.Inputs[name] = constraint + } + copySpec.Options = make(map[string]OptionConstraint, len(spec.Options)) + for name, constraint := range spec.Options { + values := append([]any(nil), constraint.Values...) + sort.SliceStable(values, func(i, j int) bool { return normalizedScalar(values[i]) < normalizedScalar(values[j]) }) + constraint.Values = values + copySpec.Options[name] = constraint + } + encoded, _ := json.Marshal(copySpec) + return string(encoded) +} diff --git a/backend/internal/app/logical_models_coverage.go b/backend/internal/app/logical_models_coverage.go new file mode 100644 index 000000000..f521cb5b5 --- /dev/null +++ b/backend/internal/app/logical_models_coverage.go @@ -0,0 +1,200 @@ +// 逻辑模型的产品规格覆盖校验:前台逻辑模型承诺的能力(输入、选项、取值范围) +// 必须被至少一条可用路由覆盖,否则保存时拒绝,避免用户选了一个实际无法执行的规格。 + +package app + +import ( + "math" +) + +// 前台模型只声明供应线路真实提供的总目录;组合是否可承接仍由匿名 capabilityProfiles 按 OR 语义判断。 +func validateProductSpecWithinRoutes(product CapabilitySpec, routeSpecs []CapabilitySpec) error { + for _, routeSpec := range routeSpecs { + if normalizeCapability(routeSpec.Capability) != normalizeCapability(product.Capability) { + return BadAuthRequest("供应线路能力类型与前台模型不一致") + } + } + if len(product.Operations) == 0 { + unrestricted := false + for _, routeSpec := range routeSpecs { + if len(routeSpec.Operations) == 0 { + unrestricted = true + break + } + } + if !unrestricted { + return BadAuthRequest("创作端生成方式必须从供应线路支持的选项中选择") + } + } else { + for _, operation := range product.Operations { + supported := false + for _, routeSpec := range routeSpecs { + if len(routeSpec.Operations) == 0 || containsCapabilityString(routeSpec.Operations, operation) { + supported = true + break + } + } + if !supported { + return BadAuthRequest("创作端生成方式不受任何供应线路支持:" + operation) + } + } + } + for name, constraint := range product.Inputs { + if !inputConstraintCovered(constraint, name, routeSpecs) { + return BadAuthRequest("创作端输入范围超出供应线路能力:" + name) + } + } + for name, constraint := range product.Options { + if !optionConstraintCovered(constraint, name, routeSpecs) { + return BadAuthRequest("创作端参数超出供应线路能力:" + name) + } + } + return nil +} + +func inputConstraintCovered(candidate InputConstraint, name string, routeSpecs []CapabilitySpec) bool { + next := candidate.Min + for next <= candidate.Max { + coveredUntil := next - 1 + for _, routeSpec := range routeSpecs { + constraint, exists := routeSpec.Inputs[name] + if !exists { + constraint = InputConstraint{Min: 0, Max: 0} + } + if constraint.Min <= next && constraint.Max >= next && constraint.Max > coveredUntil { + coveredUntil = constraint.Max + } + } + if coveredUntil < next { + return false + } + next = coveredUntil + 1 + } + return true +} + +func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs []CapabilitySpec) bool { + routeConstraints := make([]OptionConstraint, 0, len(routeSpecs)) + for _, routeSpec := range routeSpecs { + if constraint, exists := routeSpec.Options[name]; exists { + routeConstraints = append(routeConstraints, constraint) + } + } + if len(routeConstraints) == 0 { + return false + } + for _, routeConstraint := range routeConstraints { + if isWildcardOptionConstraint(routeConstraint) { + return true + } + } + if len(candidate.Values) > 0 { + for _, value := range candidate.Values { + if !optionValueSupported(name, value, routeConstraints) { + return false + } + } + return true + } + if candidate.Min == nil || candidate.Max == nil { + return false + } + if math.Abs(*candidate.Max-*candidate.Min) < 1e-9 { + return optionValueSupported(name, *candidate.Min, routeConstraints) + } + if candidate.Step == nil { + return continuousOptionRangeCovered(*candidate.Min, *candidate.Max, routeConstraints) + } + step := *candidate.Step + count := int(math.Floor((*candidate.Max-*candidate.Min)/step+1e-9)) + 1 + if count <= 10000 { + for index := 0; index < count; index++ { + value := *candidate.Min + float64(index)*step + if !optionValueSupported(name, value, routeConstraints) { + return false + } + } + return true + } + // 超大离散范围不逐点展开;只有单条连续范围或步长完全兼容的线路才能作为可靠来源。 + for _, routeConstraint := range routeConstraints { + if routeConstraint.Min == nil || routeConstraint.Max == nil || *routeConstraint.Min > *candidate.Min || *routeConstraint.Max < *candidate.Max { + continue + } + if routeConstraint.Step == nil { + return true + } + startSteps := (*candidate.Min - *routeConstraint.Min) / *routeConstraint.Step + stepRatio := step / *routeConstraint.Step + if math.Abs(startSteps-math.Round(startSteps)) < 1e-9 && math.Abs(stepRatio-math.Round(stepRatio)) < 1e-9 { + return true + } + } + return false +} + +func optionValueSupported(name string, value any, constraints []OptionConstraint) bool { + for _, constraint := range constraints { + if isWildcardOptionConstraint(constraint) { + return true + } + if matchOptionConstraint(name, constraint, value) { + return true + } + } + return false +} + +func isWildcardOptionConstraint(constraint OptionConstraint) bool { + for _, value := range constraint.Values { + if normalizedScalar(value) == "*" { + return true + } + } + return false +} + +func continuousOptionRangeCovered(minimum float64, maximum float64, constraints []OptionConstraint) bool { + next := minimum + for next <= maximum+1e-9 { + coveredUntil := next + advanced := false + for _, constraint := range constraints { + if constraint.Min == nil || constraint.Max == nil || constraint.Step != nil { + continue + } + if *constraint.Min <= next+1e-9 && *constraint.Max >= next-1e-9 && *constraint.Max > coveredUntil { + coveredUntil = *constraint.Max + advanced = true + } + } + if coveredUntil >= maximum-1e-9 { + return true + } + if !advanced { + return false + } + next = coveredUntil + } + return true +} + +func anyValues(values []string) OptionConstraint { + result := make([]any, 0, len(values)) + for _, value := range values { + result = append(result, value) + } + return OptionConstraint{Values: result} +} + +func boolValues(supportsTrue bool) OptionConstraint { + values := []any{false} + if supportsTrue { + values = append(values, true) + } + return OptionConstraint{Values: values} +} + +func numericRange(minimum float64, maximum float64, step float64) OptionConstraint { + return OptionConstraint{Min: &minimum, Max: &maximum, Step: &step} +} diff --git a/backend/internal/app/model_capability.go b/backend/internal/app/model_capability.go index f0bc69a03..b55ff42f7 100644 --- a/backend/internal/app/model_capability.go +++ b/backend/internal/app/model_capability.go @@ -4,10 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "sort" - "strconv" "strings" - "unicode/utf8" "infinite-canvas/backend/internal/model" ) @@ -129,157 +126,6 @@ type VideoBooleanConfig struct { Default bool `json:"default"` } -func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig { - return DefaultModelCapabilityConfigForModel(protocol, "") -} - -func videoDurationSupported(value *VideoCapabilityConfig) bool { - return value == nil || value.DurationSupported == nil || *value.DurationSupported -} - -func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapabilityConfig { - image := &ImageCapabilityConfig{ - References: ImageReferenceConfig{PromptMaxChars: 32000, MaxImages: 16, MaxImageBytes: 30 * 1024 * 1024, MaskSupported: true}, - Size: ImageSizeConfig{Parameter: "size", Values: defaultImageSizeValues(), Default: "1:1", AllowCustom: true}, - Quality: ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "auto"}, - TransparentBackground: VideoBooleanConfig{Supported: true, Default: false}, - ResponseFormat: ParameterSupport{Supported: true}, - OutputFormat: ParameterSupport{Supported: true}, - MaxOutputs: 15, - } - switch model.ChannelInterfaceType(protocol) { - case model.ChannelInterfaceGrokImage: - image.References.MaxImages = 1 - image.References.MaskSupported = false - // grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。 - image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} - image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} - image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} - image.ResponseFormat = ParameterSupport{Supported: true} - image.OutputFormat = ParameterSupport{Supported: false} - image.MaxOutputs = 1 - case model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineArkAgentPlanImage: - image.References.MaskSupported = false - image.Quality.Supported = false - image.TransparentBackground.Supported = false - image.ResponseFormat.Supported = false - image.OutputFormat.Supported = false - case model.ChannelInterfaceVolcengineJiMengImage: - image.References.MaxImages = 14 - image.References.MaskSupported = false - image.Quality.Supported = false - image.TransparentBackground.Supported = false - image.ResponseFormat.Supported = false - image.OutputFormat.Supported = false - case model.ChannelInterfaceGeminiImage: - image.References.MaskSupported = false - // Gemini Images uses imageConfig.aspectRatio, not the OpenAI-style pixel size field. - image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"auto", "1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"}, Default: "1:1", AllowCustom: false} - image.TransparentBackground.Supported = false - image.ResponseFormat.Supported = false - image.OutputFormat.Supported = false - image.MaxOutputs = 4 - } - if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") { - image.References.MaxImages = 0 - image.References.MaskSupported = false - image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} - image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} - image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} - image.ResponseFormat = ParameterSupport{Supported: true} - image.OutputFormat = ParameterSupport{Supported: false} - image.MaxOutputs = 1 - } - return image -} - -func defaultImageSizeValues() []string { - return []string{ - "auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", - "1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824", - "2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752", - "2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840", - } -} - -// legacyImageSizeValues 用于修复旧数据中仅保存了 "*" 的图片尺寸能力。 -// 这组值是前后台共同展示的基础预设,不能让历史通配符配置继续污染用户生成参数。 -func legacyImageSizeValues() []string { - return []string{ - "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", - "1024x1024", "1536x1024", "1024x1536", - } -} - -func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { - // 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。 - streaming := true - text := &TextCapabilityConfig{Streaming: &streaming, ContextWindowTokens: 128000, MaxOutputTokens: 16384, References: TextReferenceConfig{PromptMaxChars: 32000}} - video := &VideoCapabilityConfig{ - References: VideoReferenceConfig{PromptMaxChars: DefaultVideoPromptMaxChars, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, - Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, - Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, - DefaultRatio: "16:9", - Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"}, - DefaultResolution: "720p", - GenerateAudio: VideoBooleanConfig{Supported: false, Default: false}, - Watermark: VideoBooleanConfig{Supported: false, Default: false}, - Operations: []string{"text_to_video", "image_to_video"}, - DefaultOperation: "text_to_video", - } - switch model.ChannelInterfaceType(protocol) { - case model.ChannelInterfaceVolcengineJiMengVideo: - video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5} - video.Resolutions = []string{"720p"} - case model.ChannelInterfaceGeminiVeo: - video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 6, 8}, Default: 6} - video.Resolutions = []string{"720p", "1080p"} - case model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineArkAgentPlanVideo: - video.Operations = append(video.Operations, "reference_to_video", "audio_to_video") - video.References.MaxVideos, video.References.MaxAudios = 3, 3 - video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 - video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 - video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} - video.Watermark = VideoBooleanConfig{Supported: true, Default: false} - video.Resolutions = []string{"480p", "720p", "1080p"} - case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2: - video.References.MaxVideos, video.References.MaxAudios = 3, 3 - video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 - video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 - video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} - if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 { - video.Resolutions = []string{"480p", "720p", "1080p"} - } - case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: - video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} - case model.ChannelInterfaceNovitaVideo: - video.References.MaxImages, video.References.MaxImageBytes = 1, 10*1024*1024 - video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5} - video.Ratios = []string{"16:9", "9:16", "1:1"} - video.Resolutions = []string{"1080p"} - video.DefaultResolution = "1080p" - case model.ChannelInterfaceMiniMaxVideo: - video.Operations = append(video.Operations, "reference_to_video") - video.References.MaxImages = 9 - video.References.MaxImageBytes = 30 * 1024 * 1024 - video.References.MaxVideos = 3 - video.References.MaxVideoBytes = 50 * 1024 * 1024 - video.References.MaxVideoDuration = 15 - video.References.MaxAudios = 3 - video.References.MaxAudioBytes = 15 * 1024 * 1024 - video.References.MaxAudioDuration = 15 - video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Default: 5} - video.Ratios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} - video.DefaultRatio = "16:9" - video.Resolutions = []string{"768P", "2K"} - video.DefaultResolution = "768P" - video.Watermark = VideoBooleanConfig{Supported: true, Default: false} - case model.ChannelInterfaceAgnesVideo: - video = applyModelSpecificVideoCapability(video, protocol, modelName) - } - return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video} -} - func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) { if strings.TrimSpace(raw) == "" { return nil, nil @@ -543,536 +389,3 @@ func addInputConstraint(inputs map[string]InputConstraint, name string, min int, } inputs[name] = InputConstraint{Min: min, Max: max} } - -func validateTextCapabilityConfig(value *TextCapabilityConfig) error { - if value.ContextWindowTokens < 4096 || value.ContextWindowTokens > 10000000 { - return BadAuthRequest("文本模型上下文窗口必须在 4096-10000000 Token 之间") - } - if value.MaxOutputTokens < 256 || value.MaxOutputTokens > 1000000 || value.MaxOutputTokens >= value.ContextWindowTokens { - return BadAuthRequest("文本模型最大输出 Token 必须小于上下文窗口且在 256-1000000 之间") - } - if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { - return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") - } - for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos} { - if number < 0 || number > 100 { - return BadAuthRequest(name + "必须在 0-100 之间") - } - } - if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 { - return BadAuthRequest("引用素材大小限制不能小于 0") - } - return nil -} - -func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { - if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { - return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") - } - if value.References.MaxImages < 0 || value.References.MaxImages > 100 || value.References.MaxImageBytes < 0 { - return BadAuthRequest("图片引用限制无效") - } - if value.MaxOutputs < 1 || value.MaxOutputs > 100 { - return BadAuthRequest("单次图片数量必须在 1-100 之间") - } - switch value.Size.Parameter { - case "none": - value.Size.Values = []string{} - value.Size.Presets = nil - value.Size.Default = "auto" - value.Size.AllowCustom = false - case "size", "aspect_ratio": - if strings.TrimSpace(value.Size.Default) == "" { - return BadAuthRequest("请配置默认图片尺寸或比例") - } - if !value.Size.AllowCustom && !containsCapabilityString(value.Size.Values, value.Size.Default) { - return BadAuthRequest("默认图片尺寸必须属于支持值") - } - default: - return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio") - } - seenPresets := make(map[string]bool) - for _, preset := range value.Size.Presets { - if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" { - return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K") - } - parts := strings.Split(preset.Ratio, ":") - if len(parts) != 2 { - return BadAuthRequest("图片预设比例格式无效") - } - w, ew := strconv.Atoi(parts[0]) - h, eh := strconv.Atoi(parts[1]) - if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 { - return BadAuthRequest("图片预设比例无效") - } - if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) { - return BadAuthRequest("图片预设像素尺寸无效") - } - // 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。 - difference := preset.Width*h - preset.Height*w - if difference < 0 { - difference = -difference - } - if difference*1000 > preset.Height*w*25 { - return BadAuthRequest("图片预设像素尺寸与宽高比不一致") - } - a, b := w, h - for b != 0 { - a, b = b, a%b - } - key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a) - if seenPresets[key] { - return BadAuthRequest("图片尺寸预设重复") - } - seenPresets[key] = true - requestValue := preset.Size - if value.Size.Parameter == "aspect_ratio" { - requestValue = preset.Ratio - } - if !containsCapabilityString(value.Size.Values, requestValue) { - return BadAuthRequest("图片预设必须包含在尺寸支持值中") - } - } - if value.Quality.Supported { - if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) { - return BadAuthRequest("请配置图片质量支持值和默认值") - } - } else { - value.Quality.Values = []string{} - value.Quality.Default = "auto" - } - if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil { - return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中") - } - if !value.TransparentBackground.Supported { - value.TransparentBackground.Default = false - } - return nil -} - -func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { - if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { - return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") - } - for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { - if number < 0 || number > 100 { - return BadAuthRequest(name + "必须在 0-100 之间") - } - } - if value.References.MinImages > value.References.MaxImages { - return BadAuthRequest("最少图片引用数不能超过最大图片引用数") - } - if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 { - return BadAuthRequest("引用素材限制不能小于 0") - } - if err := validateVideoDuration(value.Duration); err != nil { - return err - } - if len(value.Ratios) == 0 { - if strings.TrimSpace(value.DefaultRatio) != "" { - return BadAuthRequest("未配置画面比例时不能设置默认比例") - } - } else if strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) { - return BadAuthRequest("默认画面比例必须属于支持值") - } - if len(value.Resolutions) == 0 { - if strings.TrimSpace(value.DefaultResolution) != "" { - return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率") - } - } else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { - return BadAuthRequest("默认输出分辨率必须属于支持值") - } - if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) { - return BadAuthRequest("请至少配置一个生成模式,并选择默认模式") - } - return nil -} - -func validateVideoDuration(value VideoDurationConfig) error { - switch value.Selection { - case "range": - if value.Min < 1 || value.Max < value.Min || value.Max > 3600 || value.Step < 1 || value.Default < value.Min || value.Default > value.Max || (value.Default-value.Min)%value.Step != 0 { - return BadAuthRequest("视频时长范围或默认值无效") - } - case "enum": - if len(value.Values) == 0 || len(value.Values) > 100 { - return BadAuthRequest("视频固定时长至少需要一个选项") - } - values := append([]int(nil), value.Values...) - sort.Ints(values) - for index, item := range values { - if item < 1 || item > 3600 || (index > 0 && values[index-1] == item) { - return BadAuthRequest("视频固定时长选项无效或重复") - } - } - if !containsInt(values, value.Default) { - return BadAuthRequest("视频默认时长必须属于固定时长选项") - } - default: - return BadAuthRequest("视频时长选择方式仅支持范围或固定值") - } - return nil -} - -func (s *Service) ValidateTaskCapability(input map[string]any) error { - encoded, err := json.Marshal(input) - if err != nil { - return BadAuthRequest("任务输入格式无效") - } - var taskInput canvasGenerationInput - if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video" && taskInput.Mode != "audio") { - return nil - } - if isWorkflowProviderInterface(taskInput.Config.InterfaceType) { - if err := validateWorkflowProviderPromptLength(taskInput); err != nil { - return err - } - return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config) - } - // 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。 - if taskInput.Mode == "audio" { - return nil - } - channelID := strings.TrimSpace(taskInput.Config.ChannelID) - if channelID == "" { - channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL) - } - if channelID == "" { - if taskInput.Mode == "image" { - profile := DefaultImageCapabilityConfig(taskInput.Config.InterfaceType, taskInput.Config.Model) - if taskInput.Config.CapabilityConfig != nil && taskInput.Config.CapabilityConfig.Image != nil { - profile = taskInput.Config.CapabilityConfig.Image - } - return validateImageTask(profile, taskInput) - } - profile := taskInput.Config.CapabilityConfig - if profile == nil || profile.Video == nil { - if taskInput.Config.InterfaceType != string(model.ChannelInterfaceAgnesVideo) { - return nil - } - profile = DefaultModelCapabilityConfigForModel(taskInput.Config.InterfaceType, taskInput.Config.Model) - } - normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", taskInput.Config.InterfaceType, taskInput.Config.Model, profile) - if normalizeErr != nil || normalized == nil || normalized.Video == nil { - return BadAuthRequest("当前视频模型能力参数无效") - } - return validateVideoTask(normalized.Video, taskInput) - } - item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(taskInput.Config)) - if err != nil { - return BadAuthRequest("当前系统渠道模型未配置或已停用") - } - profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) - if taskInput.Mode == "image" { - if err != nil { - return BadAuthRequest("当前图片模型能力参数无效") - } - imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey)) - if profile != nil && profile.Image != nil { - imageProfile = profile.Image - } - return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput) - } - if err != nil || profile == nil || profile.Video == nil { - return BadAuthRequest("当前视频模型尚未配置能力参数") - } - normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), profile) - if normalizeErr != nil || normalized == nil || normalized.Video == nil { - return BadAuthRequest("当前视频模型能力参数无效") - } - applyFixedVideoResolution(&taskInput, normalized.Video) - if config, ok := input["config"].(map[string]any); ok { - config["vquality"] = taskInput.Config.VQuality - } - return validateVideoTask(normalized.Video, taskInput) -} - -// applyModelSpecificImageCapability is retained as a narrow normalization hook -// for provider-specific image validation. The stored capability profile is -// already normalized when the channel model is saved, so no second override is -// needed here. -func applyModelSpecificImageCapability(profile *ImageCapabilityConfig, _ string, _ string, _ string) *ImageCapabilityConfig { - return profile -} - -// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。 -func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) { - if input == nil || profile == nil || len(profile.Resolutions) != 1 { - return - } - if resolution := videoResolutionNameRequest(profile, profile.Resolutions[0]); resolution != "" { - input.Config.VQuality = resolution - } -} - -func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error { - if profile == nil { - return BadAuthRequest("当前视频模型能力参数无效") - } - if err := validateModelPromptLength("视频", input.Prompt, profile.References.PromptMaxChars); err != nil { - return err - } - if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { - return BadAuthRequest("参考素材数量超过当前模型限制") - } - if model.IsVolcengineArkVideoProtocol(model.ChannelInterfaceType(input.Config.InterfaceType)) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 { - return BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频") - } - if len(input.ReferenceImages) < profile.References.MinImages { - return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)) - } - for _, media := range input.ReferenceImages { - if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { - return BadAuthRequest("参考图片文件超过当前模型大小限制") - } - } - for _, media := range input.ReferenceVideos { - if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes { - return BadAuthRequest("参考视频文件超过当前模型大小限制") - } - if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 { - return BadAuthRequest("参考视频时长超过当前模型限制") - } - } - for _, media := range input.ReferenceAudios { - if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes { - return BadAuthRequest("参考音频文件超过当前模型大小限制") - } - if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 { - return BadAuthRequest("参考音频时长超过当前模型限制") - } - } - seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)) - if err != nil || !videoDurationAllowed(profile.Duration, seconds) { - return BadAuthRequest("视频时长不在当前模型支持范围内") - } - if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) { - return BadAuthRequest("画面比例不在当前模型支持范围内") - } - if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" { - return BadAuthRequest("输出分辨率不在当前模型支持范围内") - } - operation := metadataString(input.Metadata, "videoEditOperation") - if operation == "" { - if len(input.ReferenceImages) > 0 { - operation = "image_to_video" - } else { - operation = profile.DefaultOperation - } - } - if !containsCapabilityString(profile.Operations, operation) { - return BadAuthRequest("当前视频模型不支持该生成模式") - } - return nil -} - -func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInput) error { - if profile == nil { - return nil - } - modelName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input.Config.Model)), "models/") - if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) && modelName == "grok-imagine-image-quality" { - const maxPromptBytes = 8000 - promptBytes := len(withSystemPrompt(input.Config, input.Prompt)) - if promptBytes > maxPromptBytes { - return BadAuthRequest(fmt.Sprintf("Grok 图片完整提示词为 %d UTF-8 字节,超过上游 %d 字节限制。系统不会自动删改;请精简当前输入、连线文本、角色卡、画风或模板内容后重试", promptBytes, maxPromptBytes)) - } - } - if len(input.ReferenceImages) > profile.References.MaxImages { - return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)) - } - for _, media := range input.ReferenceImages { - if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { - return BadAuthRequest("参考图片文件超过当前模型大小限制") - } - } - if input.Mask != nil && !profile.References.MaskSupported { - return BadAuthRequest("当前图片模型不支持蒙版编辑") - } - if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) { - return BadAuthRequest("图片尺寸不在当前模型支持范围内") - } - if profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) { - if err := validateGPTImage2CustomSize(input.Config.Size); err != nil { - return BadAuthRequest(err.Error()) - } - } - quality := strings.TrimSpace(input.Config.Quality) - if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) { - return BadAuthRequest("图片质量不在当前模型支持范围内") - } - if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { - return err - } - count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) - if err == nil && count > profile.MaxOutputs { - return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)) - } - return nil -} - -func qualityForImageValidation(quality string, fallback string) string { - if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") { - return fallback - } - return firstNonEmpty(quality, fallback) -} - -func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error { - if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { - return nil - } - tier := imageResolutionTier(quality) - if tier == "" { - return nil - } - for _, preset := range profile.Size.Presets { - if preset.Tier == tier && preset.Ratio == ratio { - return nil - } - } - return BadAuthRequest("当前分辨率不支持所选图片宽高比") -} - -func imageResolutionTier(quality string) string { - switch strings.ToLower(strings.TrimSpace(quality)) { - case "1k", "low": - return "1k" - case "2k", "medium": - return "2k" - case "4k", "high": - return "4k" - default: - return "" - } -} - -func validateWorkflowProviderPromptLength(input canvasGenerationInput) error { - profile := input.Config.CapabilityConfig - if input.Mode != "video" || profile == nil || profile.Video == nil { - return nil - } - return validateModelPromptLength("视频", input.Prompt, profile.Video.References.PromptMaxChars) -} - -func validateModelPromptLength(label string, prompt string, maxChars int) error { - if maxChars <= 0 { - return nil - } - actualChars := utf8.RuneCountInString(prompt) - if actualChars <= maxChars { - return nil - } - return BadAuthRequest(fmt.Sprintf("当前%s模型提示词最多 %d 个字符,完整提示词为 %d 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试", label, maxChars, actualChars)) -} - -func validateGPTImage2CustomSize(value string) error { - value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) - if value == "" || value == "auto" { - return nil - } - parts := strings.Split(value, "x") - if len(parts) != 2 { - return errors.New("自定义图片尺寸请使用宽x高,例如 3840x1920") - } - width, widthErr := strconv.Atoi(parts[0]) - height, heightErr := strconv.Atoi(parts[1]) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { - return errors.New("图片尺寸必须是正整数") - } - if width%16 != 0 || height%16 != 0 { - return errors.New("图片尺寸宽高必须是 16 的倍数") - } - if max(width, height) > 3840 { - return errors.New("图片尺寸最长边不能超过 3840px") - } - if max(width, height) > min(width, height)*3 { - return errors.New("图片宽高比不能超过 3:1") - } - pixels := int64(width) * int64(height) - if pixels < 655360 || pixels > 8294400 { - return errors.New("图片总像素需在 655360 到 8294400 之间") - } - return nil -} - -func videoDurationAllowed(value VideoDurationConfig, seconds int) bool { - if value.Selection == "enum" { - return containsInt(value.Values, seconds) - } - return seconds >= value.Min && seconds <= value.Max && value.Step > 0 && (seconds-value.Min)%value.Step == 0 -} - -func videoRatioAllowed(options []string, value string) bool { - value = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(value, "×", "x"))) - if containsCapabilityString(options, value) { - return true - } - parts := strings.Split(value, "x") - if len(parts) != 2 { - return false - } - width, widthErr := strconv.ParseFloat(parts[0], 64) - height, heightErr := strconv.ParseFloat(parts[1], 64) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { - return false - } - actual := width / height - for _, option := range options { - candidate := ratioValue(option) - if candidate > 0 && absFloat(candidate-actual)/candidate < 0.01 { - return true - } - } - return false -} - -func ratioValue(value string) float64 { - parts := strings.Split(strings.TrimSpace(value), ":") - if len(parts) != 2 { - return 0 - } - width, widthErr := strconv.ParseFloat(parts[0], 64) - height, heightErr := strconv.ParseFloat(parts[1], 64) - if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { - return 0 - } - return width / height -} - -func normalizeResolution(value string) string { - value = strings.ToLower(strings.TrimSpace(value)) - value = strings.TrimSuffix(value, "p") - if value == "2k" { - return "1440p" - } - if value == "4k" { - return "2160p" - } - return value + "p" -} - -func containsCapabilityString(values []string, target string) bool { - for _, value := range values { - if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) { - return true - } - } - return false -} - -func containsInt(values []int, target int) bool { - for _, value := range values { - if value == target { - return true - } - } - return false -} - -func absFloat(value float64) float64 { - if value < 0 { - return -value - } - return value -} diff --git a/backend/internal/app/model_capability_defaults.go b/backend/internal/app/model_capability_defaults.go new file mode 100644 index 000000000..a633c8533 --- /dev/null +++ b/backend/internal/app/model_capability_defaults.go @@ -0,0 +1,160 @@ +// 各类模型的默认能力配置与按模型的特例修正(例如固定分辨率、特定时长档位)。 + +package app + +import ( + "strings" + + "infinite-canvas/backend/internal/model" +) + +func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig { + return DefaultModelCapabilityConfigForModel(protocol, "") +} + +func videoDurationSupported(value *VideoCapabilityConfig) bool { + return value == nil || value.DurationSupported == nil || *value.DurationSupported +} + +func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapabilityConfig { + image := &ImageCapabilityConfig{ + References: ImageReferenceConfig{PromptMaxChars: 32000, MaxImages: 16, MaxImageBytes: 30 * 1024 * 1024, MaskSupported: true}, + Size: ImageSizeConfig{Parameter: "size", Values: defaultImageSizeValues(), Default: "1:1", AllowCustom: true}, + Quality: ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "auto"}, + TransparentBackground: VideoBooleanConfig{Supported: true, Default: false}, + ResponseFormat: ParameterSupport{Supported: true}, + OutputFormat: ParameterSupport{Supported: true}, + MaxOutputs: 15, + } + switch model.ChannelInterfaceType(protocol) { + case model.ChannelInterfaceGrokImage: + image.References.MaxImages = 1 + image.References.MaskSupported = false + // grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。 + image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} + image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} + image.ResponseFormat = ParameterSupport{Supported: true} + image.OutputFormat = ParameterSupport{Supported: false} + image.MaxOutputs = 1 + case model.ChannelInterfaceVolcengineArkImage, model.ChannelInterfaceVolcengineArkAgentPlanImage: + image.References.MaskSupported = false + image.Quality.Supported = false + image.TransparentBackground.Supported = false + image.ResponseFormat.Supported = false + image.OutputFormat.Supported = false + case model.ChannelInterfaceVolcengineJiMengImage: + image.References.MaxImages = 14 + image.References.MaskSupported = false + image.Quality.Supported = false + image.TransparentBackground.Supported = false + image.ResponseFormat.Supported = false + image.OutputFormat.Supported = false + case model.ChannelInterfaceGeminiImage: + image.References.MaskSupported = false + // Gemini Images uses imageConfig.aspectRatio, not the OpenAI-style pixel size field. + image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"auto", "1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"}, Default: "1:1", AllowCustom: false} + image.TransparentBackground.Supported = false + image.ResponseFormat.Supported = false + image.OutputFormat.Supported = false + image.MaxOutputs = 4 + } + if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") { + image.References.MaxImages = 0 + image.References.MaskSupported = false + image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false} + image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"} + image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false} + image.ResponseFormat = ParameterSupport{Supported: true} + image.OutputFormat = ParameterSupport{Supported: false} + image.MaxOutputs = 1 + } + return image +} + +func defaultImageSizeValues() []string { + return []string{ + "auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", + "1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824", + "2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752", + "2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840", + } +} + +// legacyImageSizeValues 用于修复旧数据中仅保存了 "*" 的图片尺寸能力。 +// 这组值是前后台共同展示的基础预设,不能让历史通配符配置继续污染用户生成参数。 +func legacyImageSizeValues() []string { + return []string{ + "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16", + "1024x1024", "1536x1024", "1024x1536", + } +} + +func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig { + // 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。 + streaming := true + text := &TextCapabilityConfig{Streaming: &streaming, ContextWindowTokens: 128000, MaxOutputTokens: 16384, References: TextReferenceConfig{PromptMaxChars: 32000}} + video := &VideoCapabilityConfig{ + References: VideoReferenceConfig{PromptMaxChars: DefaultVideoPromptMaxChars, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0}, + Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6}, + Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"}, + DefaultRatio: "16:9", + Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"}, + DefaultResolution: "720p", + GenerateAudio: VideoBooleanConfig{Supported: false, Default: false}, + Watermark: VideoBooleanConfig{Supported: false, Default: false}, + Operations: []string{"text_to_video", "image_to_video"}, + DefaultOperation: "text_to_video", + } + switch model.ChannelInterfaceType(protocol) { + case model.ChannelInterfaceVolcengineJiMengVideo: + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5} + video.Resolutions = []string{"720p"} + case model.ChannelInterfaceGeminiVeo: + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 6, 8}, Default: 6} + video.Resolutions = []string{"720p", "1080p"} + case model.ChannelInterfaceVolcengineArkVideo, model.ChannelInterfaceVolcengineArkAgentPlanVideo: + video.Operations = append(video.Operations, "reference_to_video", "audio_to_video") + video.References.MaxVideos, video.References.MaxAudios = 3, 3 + video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 + video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 + video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + video.Watermark = VideoBooleanConfig{Supported: true, Default: false} + video.Resolutions = []string{"480p", "720p", "1080p"} + case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2: + video.References.MaxVideos, video.References.MaxAudios = 3, 3 + video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024 + video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15 + video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true} + if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 { + video.Resolutions = []string{"480p", "720p", "1080p"} + } + case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo: + video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false} + case model.ChannelInterfaceNovitaVideo: + video.References.MaxImages, video.References.MaxImageBytes = 1, 10*1024*1024 + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5} + video.Ratios = []string{"16:9", "9:16", "1:1"} + video.Resolutions = []string{"1080p"} + video.DefaultResolution = "1080p" + case model.ChannelInterfaceMiniMaxVideo: + video.Operations = append(video.Operations, "reference_to_video") + video.References.MaxImages = 9 + video.References.MaxImageBytes = 30 * 1024 * 1024 + video.References.MaxVideos = 3 + video.References.MaxVideoBytes = 50 * 1024 * 1024 + video.References.MaxVideoDuration = 15 + video.References.MaxAudios = 3 + video.References.MaxAudioBytes = 15 * 1024 * 1024 + video.References.MaxAudioDuration = 15 + video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Default: 5} + video.Ratios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} + video.DefaultRatio = "16:9" + video.Resolutions = []string{"768P", "2K"} + video.DefaultResolution = "768P" + video.Watermark = VideoBooleanConfig{Supported: true, Default: false} + case model.ChannelInterfaceAgnesVideo: + video = applyModelSpecificVideoCapability(video, protocol, modelName) + } + return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video} +} diff --git a/backend/internal/app/model_capability_validate.go b/backend/internal/app/model_capability_validate.go new file mode 100644 index 000000000..653658353 --- /dev/null +++ b/backend/internal/app/model_capability_validate.go @@ -0,0 +1,550 @@ +// 按模型能力配置校验生成任务:尺寸、质量、时长、比例、参考数量与提示词长度。 +// +// 校验在任务创建前完成,不合规的请求直接拒绝,不会产生计费订单。 + +package app + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "unicode/utf8" + + "infinite-canvas/backend/internal/model" +) + +func validateTextCapabilityConfig(value *TextCapabilityConfig) error { + if value.ContextWindowTokens < 4096 || value.ContextWindowTokens > 10000000 { + return BadAuthRequest("文本模型上下文窗口必须在 4096-10000000 Token 之间") + } + if value.MaxOutputTokens < 256 || value.MaxOutputTokens > 1000000 || value.MaxOutputTokens >= value.ContextWindowTokens { + return BadAuthRequest("文本模型最大输出 Token 必须小于上下文窗口且在 256-1000000 之间") + } + if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { + return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") + } + for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos} { + if number < 0 || number > 100 { + return BadAuthRequest(name + "必须在 0-100 之间") + } + } + if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 { + return BadAuthRequest("引用素材大小限制不能小于 0") + } + return nil +} + +func validateImageCapabilityConfig(value *ImageCapabilityConfig) error { + if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { + return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") + } + if value.References.MaxImages < 0 || value.References.MaxImages > 100 || value.References.MaxImageBytes < 0 { + return BadAuthRequest("图片引用限制无效") + } + if value.MaxOutputs < 1 || value.MaxOutputs > 100 { + return BadAuthRequest("单次图片数量必须在 1-100 之间") + } + switch value.Size.Parameter { + case "none": + value.Size.Values = []string{} + value.Size.Presets = nil + value.Size.Default = "auto" + value.Size.AllowCustom = false + case "size", "aspect_ratio": + if strings.TrimSpace(value.Size.Default) == "" { + return BadAuthRequest("请配置默认图片尺寸或比例") + } + if !value.Size.AllowCustom && !containsCapabilityString(value.Size.Values, value.Size.Default) { + return BadAuthRequest("默认图片尺寸必须属于支持值") + } + default: + return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio") + } + seenPresets := make(map[string]bool) + for _, preset := range value.Size.Presets { + if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" { + return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K") + } + parts := strings.Split(preset.Ratio, ":") + if len(parts) != 2 { + return BadAuthRequest("图片预设比例格式无效") + } + w, ew := strconv.Atoi(parts[0]) + h, eh := strconv.Atoi(parts[1]) + if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 { + return BadAuthRequest("图片预设比例无效") + } + if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) { + return BadAuthRequest("图片预设像素尺寸无效") + } + // 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。 + difference := preset.Width*h - preset.Height*w + if difference < 0 { + difference = -difference + } + if difference*1000 > preset.Height*w*25 { + return BadAuthRequest("图片预设像素尺寸与宽高比不一致") + } + a, b := w, h + for b != 0 { + a, b = b, a%b + } + key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a) + if seenPresets[key] { + return BadAuthRequest("图片尺寸预设重复") + } + seenPresets[key] = true + requestValue := preset.Size + if value.Size.Parameter == "aspect_ratio" { + requestValue = preset.Ratio + } + if !containsCapabilityString(value.Size.Values, requestValue) { + return BadAuthRequest("图片预设必须包含在尺寸支持值中") + } + } + if value.Quality.Supported { + if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) { + return BadAuthRequest("请配置图片质量支持值和默认值") + } + } else { + value.Quality.Values = []string{} + value.Quality.Default = "auto" + } + if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil { + return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中") + } + if !value.TransparentBackground.Supported { + value.TransparentBackground.Default = false + } + return nil +} + +func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error { + if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 { + return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间") + } + for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} { + if number < 0 || number > 100 { + return BadAuthRequest(name + "必须在 0-100 之间") + } + } + if value.References.MinImages > value.References.MaxImages { + return BadAuthRequest("最少图片引用数不能超过最大图片引用数") + } + if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 { + return BadAuthRequest("引用素材限制不能小于 0") + } + if err := validateVideoDuration(value.Duration); err != nil { + return err + } + if len(value.Ratios) == 0 { + if strings.TrimSpace(value.DefaultRatio) != "" { + return BadAuthRequest("未配置画面比例时不能设置默认比例") + } + } else if strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) { + return BadAuthRequest("默认画面比例必须属于支持值") + } + if len(value.Resolutions) == 0 { + if strings.TrimSpace(value.DefaultResolution) != "" { + return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率") + } + } else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) { + return BadAuthRequest("默认输出分辨率必须属于支持值") + } + if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) { + return BadAuthRequest("请至少配置一个生成模式,并选择默认模式") + } + return nil +} + +func validateVideoDuration(value VideoDurationConfig) error { + switch value.Selection { + case "range": + if value.Min < 1 || value.Max < value.Min || value.Max > 3600 || value.Step < 1 || value.Default < value.Min || value.Default > value.Max || (value.Default-value.Min)%value.Step != 0 { + return BadAuthRequest("视频时长范围或默认值无效") + } + case "enum": + if len(value.Values) == 0 || len(value.Values) > 100 { + return BadAuthRequest("视频固定时长至少需要一个选项") + } + values := append([]int(nil), value.Values...) + sort.Ints(values) + for index, item := range values { + if item < 1 || item > 3600 || (index > 0 && values[index-1] == item) { + return BadAuthRequest("视频固定时长选项无效或重复") + } + } + if !containsInt(values, value.Default) { + return BadAuthRequest("视频默认时长必须属于固定时长选项") + } + default: + return BadAuthRequest("视频时长选择方式仅支持范围或固定值") + } + return nil +} + +func (s *Service) ValidateTaskCapability(input map[string]any) error { + encoded, err := json.Marshal(input) + if err != nil { + return BadAuthRequest("任务输入格式无效") + } + var taskInput canvasGenerationInput + if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video" && taskInput.Mode != "audio") { + return nil + } + if isWorkflowProviderInterface(taskInput.Config.InterfaceType) { + if err := validateWorkflowProviderPromptLength(taskInput); err != nil { + return err + } + return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config) + } + // 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。 + if taskInput.Mode == "audio" { + return nil + } + channelID := strings.TrimSpace(taskInput.Config.ChannelID) + if channelID == "" { + channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL) + } + if channelID == "" { + if taskInput.Mode == "image" { + profile := DefaultImageCapabilityConfig(taskInput.Config.InterfaceType, taskInput.Config.Model) + if taskInput.Config.CapabilityConfig != nil && taskInput.Config.CapabilityConfig.Image != nil { + profile = taskInput.Config.CapabilityConfig.Image + } + return validateImageTask(profile, taskInput) + } + profile := taskInput.Config.CapabilityConfig + if profile == nil || profile.Video == nil { + if taskInput.Config.InterfaceType != string(model.ChannelInterfaceAgnesVideo) { + return nil + } + profile = DefaultModelCapabilityConfigForModel(taskInput.Config.InterfaceType, taskInput.Config.Model) + } + normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", taskInput.Config.InterfaceType, taskInput.Config.Model, profile) + if normalizeErr != nil || normalized == nil || normalized.Video == nil { + return BadAuthRequest("当前视频模型能力参数无效") + } + return validateVideoTask(normalized.Video, taskInput) + } + item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(taskInput.Config)) + if err != nil { + return BadAuthRequest("当前系统渠道模型未配置或已停用") + } + profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if taskInput.Mode == "image" { + if err != nil { + return BadAuthRequest("当前图片模型能力参数无效") + } + imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey)) + if profile != nil && profile.Image != nil { + imageProfile = profile.Image + } + return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput) + } + if err != nil || profile == nil || profile.Video == nil { + return BadAuthRequest("当前视频模型尚未配置能力参数") + } + normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), profile) + if normalizeErr != nil || normalized == nil || normalized.Video == nil { + return BadAuthRequest("当前视频模型能力参数无效") + } + applyFixedVideoResolution(&taskInput, normalized.Video) + if config, ok := input["config"].(map[string]any); ok { + config["vquality"] = taskInput.Config.VQuality + } + return validateVideoTask(normalized.Video, taskInput) +} + +// applyModelSpecificImageCapability is retained as a narrow normalization hook +// for provider-specific image validation. The stored capability profile is +// already normalized when the channel model is saved, so no second override is +// needed here. +func applyModelSpecificImageCapability(profile *ImageCapabilityConfig, _ string, _ string, _ string) *ImageCapabilityConfig { + return profile +} + +// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。 +func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) { + if input == nil || profile == nil || len(profile.Resolutions) != 1 { + return + } + if resolution := videoResolutionNameRequest(profile, profile.Resolutions[0]); resolution != "" { + input.Config.VQuality = resolution + } +} + +func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error { + if profile == nil { + return BadAuthRequest("当前视频模型能力参数无效") + } + if err := validateModelPromptLength("视频", input.Prompt, profile.References.PromptMaxChars); err != nil { + return err + } + if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios { + return BadAuthRequest("参考素材数量超过当前模型限制") + } + if model.IsVolcengineArkVideoProtocol(model.ChannelInterfaceType(input.Config.InterfaceType)) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 { + return BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频") + } + if len(input.ReferenceImages) < profile.References.MinImages { + return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)) + } + for _, media := range input.ReferenceImages { + if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { + return BadAuthRequest("参考图片文件超过当前模型大小限制") + } + } + for _, media := range input.ReferenceVideos { + if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes { + return BadAuthRequest("参考视频文件超过当前模型大小限制") + } + if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 { + return BadAuthRequest("参考视频时长超过当前模型限制") + } + } + for _, media := range input.ReferenceAudios { + if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes { + return BadAuthRequest("参考音频文件超过当前模型大小限制") + } + if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 { + return BadAuthRequest("参考音频时长超过当前模型限制") + } + } + seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)) + if err != nil || !videoDurationAllowed(profile.Duration, seconds) { + return BadAuthRequest("视频时长不在当前模型支持范围内") + } + if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) { + return BadAuthRequest("画面比例不在当前模型支持范围内") + } + if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" { + return BadAuthRequest("输出分辨率不在当前模型支持范围内") + } + operation := metadataString(input.Metadata, "videoEditOperation") + if operation == "" { + if len(input.ReferenceImages) > 0 { + operation = "image_to_video" + } else { + operation = profile.DefaultOperation + } + } + if !containsCapabilityString(profile.Operations, operation) { + return BadAuthRequest("当前视频模型不支持该生成模式") + } + return nil +} + +func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInput) error { + if profile == nil { + return nil + } + modelName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input.Config.Model)), "models/") + if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) && modelName == "grok-imagine-image-quality" { + const maxPromptBytes = 8000 + promptBytes := len(withSystemPrompt(input.Config, input.Prompt)) + if promptBytes > maxPromptBytes { + return BadAuthRequest(fmt.Sprintf("Grok 图片完整提示词为 %d UTF-8 字节,超过上游 %d 字节限制。系统不会自动删改;请精简当前输入、连线文本、角色卡、画风或模板内容后重试", promptBytes, maxPromptBytes)) + } + } + if len(input.ReferenceImages) > profile.References.MaxImages { + return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)) + } + for _, media := range input.ReferenceImages { + if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes { + return BadAuthRequest("参考图片文件超过当前模型大小限制") + } + } + if input.Mask != nil && !profile.References.MaskSupported { + return BadAuthRequest("当前图片模型不支持蒙版编辑") + } + if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) { + return BadAuthRequest("图片尺寸不在当前模型支持范围内") + } + if profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) { + if err := validateGPTImage2CustomSize(input.Config.Size); err != nil { + return BadAuthRequest(err.Error()) + } + } + quality := strings.TrimSpace(input.Config.Quality) + if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) { + return BadAuthRequest("图片质量不在当前模型支持范围内") + } + if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil { + return err + } + count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)) + if err == nil && count > profile.MaxOutputs { + return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)) + } + return nil +} + +func qualityForImageValidation(quality string, fallback string) string { + if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") { + return fallback + } + return firstNonEmpty(quality, fallback) +} + +func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error { + if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" { + return nil + } + tier := imageResolutionTier(quality) + if tier == "" { + return nil + } + for _, preset := range profile.Size.Presets { + if preset.Tier == tier && preset.Ratio == ratio { + return nil + } + } + return BadAuthRequest("当前分辨率不支持所选图片宽高比") +} + +func imageResolutionTier(quality string) string { + switch strings.ToLower(strings.TrimSpace(quality)) { + case "1k", "low": + return "1k" + case "2k", "medium": + return "2k" + case "4k", "high": + return "4k" + default: + return "" + } +} + +func validateWorkflowProviderPromptLength(input canvasGenerationInput) error { + profile := input.Config.CapabilityConfig + if input.Mode != "video" || profile == nil || profile.Video == nil { + return nil + } + return validateModelPromptLength("视频", input.Prompt, profile.Video.References.PromptMaxChars) +} + +func validateModelPromptLength(label string, prompt string, maxChars int) error { + if maxChars <= 0 { + return nil + } + actualChars := utf8.RuneCountInString(prompt) + if actualChars <= maxChars { + return nil + } + return BadAuthRequest(fmt.Sprintf("当前%s模型提示词最多 %d 个字符,完整提示词为 %d 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试", label, maxChars, actualChars)) +} + +func validateGPTImage2CustomSize(value string) error { + value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) + if value == "" || value == "auto" { + return nil + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return errors.New("自定义图片尺寸请使用宽x高,例如 3840x1920") + } + width, widthErr := strconv.Atoi(parts[0]) + height, heightErr := strconv.Atoi(parts[1]) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return errors.New("图片尺寸必须是正整数") + } + if width%16 != 0 || height%16 != 0 { + return errors.New("图片尺寸宽高必须是 16 的倍数") + } + if max(width, height) > 3840 { + return errors.New("图片尺寸最长边不能超过 3840px") + } + if max(width, height) > min(width, height)*3 { + return errors.New("图片宽高比不能超过 3:1") + } + pixels := int64(width) * int64(height) + if pixels < 655360 || pixels > 8294400 { + return errors.New("图片总像素需在 655360 到 8294400 之间") + } + return nil +} + +func videoDurationAllowed(value VideoDurationConfig, seconds int) bool { + if value.Selection == "enum" { + return containsInt(value.Values, seconds) + } + return seconds >= value.Min && seconds <= value.Max && value.Step > 0 && (seconds-value.Min)%value.Step == 0 +} + +func videoRatioAllowed(options []string, value string) bool { + value = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(value, "×", "x"))) + if containsCapabilityString(options, value) { + return true + } + parts := strings.Split(value, "x") + if len(parts) != 2 { + return false + } + width, widthErr := strconv.ParseFloat(parts[0], 64) + height, heightErr := strconv.ParseFloat(parts[1], 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return false + } + actual := width / height + for _, option := range options { + candidate := ratioValue(option) + if candidate > 0 && absFloat(candidate-actual)/candidate < 0.01 { + return true + } + } + return false +} + +func ratioValue(value string) float64 { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0 + } + width, widthErr := strconv.ParseFloat(parts[0], 64) + height, heightErr := strconv.ParseFloat(parts[1], 64) + if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 { + return 0 + } + return width / height +} + +func normalizeResolution(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + value = strings.TrimSuffix(value, "p") + if value == "2k" { + return "1440p" + } + if value == "4k" { + return "2160p" + } + return value + "p" +} + +func containsCapabilityString(values []string, target string) bool { + for _, value := range values { + if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) { + return true + } + } + return false +} + +func containsInt(values []int, target int) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} + +func absFloat(value float64) float64 { + if value < 0 { + return -value + } + return value +} diff --git a/backend/internal/app/model_router.go b/backend/internal/app/model_router.go index 5a70e4b5c..a9da17afb 100644 --- a/backend/internal/app/model_router.go +++ b/backend/internal/app/model_router.go @@ -4,8 +4,6 @@ import ( "context" cryptorand "crypto/rand" "encoding/binary" - "encoding/json" - "errors" "fmt" "log" "math" @@ -15,473 +13,8 @@ import ( "time" "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/repository" ) -type CapabilityImageSizePreset struct { - Size string `json:"size"` - Tier string `json:"tier"` - Ratio string `json:"ratio"` - Width int `json:"width"` - Height int `json:"height"` -} - -type CapabilityImageSize struct { - Parameter string `json:"parameter,omitempty"` - AllowCustom bool `json:"allowCustom,omitempty"` - Presets []CapabilityImageSizePreset `json:"presets,omitempty"` -} - -type CapabilitySpec struct { - Version int `json:"version"` - Capability string `json:"capability"` - Operations []string `json:"operations,omitempty"` - Inputs map[string]InputConstraint `json:"inputs,omitempty"` - Options map[string]OptionConstraint `json:"options,omitempty"` - ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` -} - -type InputConstraint struct { - Min int `json:"min"` - Max int `json:"max"` -} - -type OptionConstraint struct { - Values []any `json:"values,omitempty"` - Min *float64 `json:"min,omitempty"` - Max *float64 `json:"max,omitempty"` - Step *float64 `json:"step,omitempty"` -} - -type ModelRequestIntent struct { - Capability string `json:"capability"` - Operation string `json:"operation,omitempty"` - Inputs map[string]int `json:"inputs,omitempty"` - Options map[string]any `json:"options,omitempty"` -} - -// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。 -func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent { - capability := normalizeCapability(fmt.Sprint(input["mode"])) - if capability == "" { - capability = capabilityFromTaskType(taskType) - } - intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}} - for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { - if values, ok := input[key].([]any); ok { - intent.Inputs[inputType] = len(values) - } - } - if mask, exists := input["mask"]; exists && mask != nil { - intent.Inputs["mask"] = 1 - } - explicitOptions := false - if options, ok := input["capabilityOptions"].(map[string]any); ok { - explicitOptions = true - for key, value := range options { - name := canonicalCapabilityOptionName(key) - // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 - // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 - if name == "quality" { - if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { - continue - } - } - intent.Options[name] = normalizeModelRequestOption(name, value) - } - } - if config, ok := input["config"].(map[string]any); ok && !explicitOptions { - for key, value := range config { - switch key { - case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": - continue - default: - canonical := canonicalCapabilityOptionName(key) - if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { - intent.Options[canonical] = normalizeModelRequestOption(canonical, value) - } - } - } - } - return intent -} - -func normalizeModelRequestOption(name string, value any) any { - canonicalName := canonicalCapabilityOptionName(name) - if canonicalName == "quality" || canonicalName == "size" { - if text, ok := value.(string); ok { - return strings.ToLower(strings.TrimSpace(text)) - } - return value - } - if canonicalName != "vquality" { - return value - } - resolution, ok := value.(string) - if !ok { - return value - } - switch strings.ToLower(strings.TrimSpace(resolution)) { - case "low", "480", "480p": - return "480p" - case "720", "720p": - return "720p" - case "1080", "1080p": - return "1080p" - case "2k", "1440", "1440p": - return "1440p" - case "4k", "2160", "2160p": - return "2160p" - default: - // Providers expose additional resolutions such as 768P and 960P. - // Canonicalize numeric P values generically so billing selectors match - // regardless of whether the client sends "960" or "960P". - pixels := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(resolution)), "p") - if numeric, err := strconv.Atoi(pixels); err == nil && numeric > 0 { - return strconv.Itoa(numeric) + "p" - } - return value - } -} - -type CapabilityMatch struct { - Matched bool `json:"matched"` - Reasons []string `json:"reasons,omitempty"` -} - -type cachedLogicalModel struct { - Model model.LogicalModel - Revision model.LogicalModelRevision - ProductSpec CapabilitySpec - Defaults map[string]any - Routes []cachedLogicalRoute -} - -type cachedLogicalRoute struct { - Route model.LogicalModelRoute - CapabilitySpec CapabilitySpec - ChannelModel model.ChannelModel -} - -type routeCatalogSnapshot struct { - LoadedAt time.Time - CatalogVersion int64 - Models map[string]cachedLogicalModel - Ordered []string -} - -type RoutedModel struct { - LogicalModel model.LogicalModel - Revision model.LogicalModelRevision - Route model.LogicalModelRoute - ChannelModel model.ChannelModel - PriceTier *model.ChannelModelPriceTier - Defaults map[string]any -} - -func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { - var spec CapabilitySpec - if err := json.Unmarshal([]byte(raw), &spec); err != nil { - return spec, BadAuthRequest("能力配置不是有效 JSON") - } - normalized, err := NormalizeCapabilitySpec(spec) - if err != nil { - return spec, err - } - return normalized, nil -} - -func ValidateCapabilitySpec(spec CapabilitySpec) error { - _, err := NormalizeCapabilitySpec(spec) - return err -} - -func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { - spec.Capability = normalizeCapability(spec.Capability) - if spec.Version != 1 { - return spec, BadAuthRequest("能力配置 version 必须为 1") - } - if spec.Capability == "" { - return spec, BadAuthRequest("能力配置必须声明 capability") - } - operations := make([]string, 0, len(spec.Operations)) - seenOperations := make(map[string]bool, len(spec.Operations)) - for _, operation := range spec.Operations { - normalized := normalizeCapabilityValue(operation) - if normalized != "" && !seenOperations[normalized] { - seenOperations[normalized] = true - operations = append(operations, normalized) - } - } - spec.Operations = operations - normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs)) - for rawName, constraint := range spec.Inputs { - name := normalizeCapabilityValue(rawName) - if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min { - return spec, BadAuthRequest("输入能力范围无效") - } - if _, exists := normalizedInputs[name]; exists { - return spec, BadAuthRequest("输入能力存在重复名称:" + name) - } - normalizedInputs[name] = constraint - } - spec.Inputs = normalizedInputs - normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) - for rawName, constraint := range spec.Options { - name := canonicalCapabilityOptionName(rawName) - if strings.TrimSpace(name) == "" { - return spec, BadAuthRequest("能力参数名称不能为空") - } - if _, exists := normalizedOptions[name]; exists { - return spec, BadAuthRequest("能力参数存在重复别名:" + name) - } - hasValues := len(constraint.Values) > 0 - hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil - if !hasValues && !hasRange { - return spec, BadAuthRequest("能力参数必须声明 values 或数值范围") - } - if hasValues && hasRange { - return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围") - } - if hasRange && (constraint.Min == nil || constraint.Max == nil) { - return spec, BadAuthRequest("数值范围必须同时声明 min 和 max") - } - if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min { - return spec, BadAuthRequest("能力参数数值范围无效") - } - if constraint.Step != nil && *constraint.Step <= 0 { - return spec, BadAuthRequest("能力参数 step 必须大于 0") - } - normalizedOptions[name] = constraint - } - spec.Options = normalizedOptions - return spec, nil -} - -func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) { - defaults := map[string]any{} - if err := json.Unmarshal([]byte(raw), &defaults); err != nil { - return nil, err - } - return normalizeLogicalDefaults(spec, defaults) -} - -func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { - reasons := make([]string, 0) - if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { - reasons = append(reasons, "能力类型不匹配") - } - if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { - reasons = append(reasons, "不支持操作 "+intent.Operation) - } - for inputType, count := range intent.Inputs { - if count < 0 { - reasons = append(reasons, "输入数量不能小于 0") - continue - } - constraint, declared := spec.Inputs[inputType] - if !declared { - if count > 0 { - reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") - } - continue - } - if count < constraint.Min || count > constraint.Max { - reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) - } - } - for inputType, constraint := range spec.Inputs { - if intent.Inputs[inputType] < constraint.Min { - reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) - } - } - for name, value := range intent.Options { - constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] - if !declared { - reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) - continue - } - if !matchOptionConstraint(name, constraint, value) { - reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") - } - } - return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} -} - -func capabilityInputLabel(name string) string { - switch normalizeCapabilityValue(name) { - case "image": - return "参考图片" - case "video": - return "参考视频" - case "audio": - return "参考音频" - case "mask": - return "蒙版" - default: - return name - } -} - -func capabilityOptionLabel(name string) string { - switch canonicalCapabilityOptionName(name) { - case "size": - return "画面尺寸" - case "quality": - return "生成质量" - case "transparentBackground": - return "透明背景" - case "count": - return "输出数量" - case "videoSeconds": - return "视频时长" - case "vquality": - return "输出分辨率" - case "videoGenerateAudio": - return "同步音频" - case "videoWatermark": - return "水印设置" - case "audioVoice": - return "音色" - case "audioFormat": - return "音频格式" - case "audioSpeed": - return "语速" - case "audioInstructions": - return "朗读指令" - default: - return name - } -} - -func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool { - if len(constraint.Values) > 0 { - for _, candidate := range constraint.Values { - if normalizedScalar(candidate) == "*" { - return true - } - if capabilityOptionValuesEqual(name, candidate, value) { - return true - } - } - return false - } - number, ok := numericScalar(value) - if !ok { - return false - } - if constraint.Min != nil && number < *constraint.Min { - return false - } - if constraint.Max != nil && number > *constraint.Max { - return false - } - if constraint.Step != nil && constraint.Min != nil { - steps := (number - *constraint.Min) / *constraint.Step - return math.Abs(steps-math.Round(steps)) < 1e-9 - } - return true -} - -func capabilityOptionValuesEqual(name string, candidate any, value any) bool { - left := normalizedScalar(candidate) - right := normalizedScalar(value) - if canonicalCapabilityOptionName(name) == "vquality" { - // Compare using the same aliases as request intents and price tiers. - left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") - right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") - } - return left == right -} - -func normalizedScalar(value any) string { - switch typed := value.(type) { - case string: - return normalizeCapabilityValue(typed) - case bool: - return strconv.FormatBool(typed) - case float64: - return strconv.FormatFloat(typed, 'f', -1, 64) - case float32: - return strconv.FormatFloat(float64(typed), 'f', -1, 64) - case int: - return strconv.Itoa(typed) - case int64: - return strconv.FormatInt(typed, 10) - default: - encoded, _ := json.Marshal(value) - return string(encoded) - } -} - -func numericScalar(value any) (float64, bool) { - switch typed := value.(type) { - case float64: - return typed, true - case float32: - return float64(typed), true - case int: - return float64(typed), true - case int64: - return float64(typed), true - case json.Number: - parsed, err := typed.Float64() - return parsed, err == nil - case string: - parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) - return parsed, err == nil - default: - return 0, false - } -} - -func containsNormalized(values []string, expected string) bool { - for _, value := range values { - if normalizeCapabilityValue(value) == expected { - return true - } - } - return false -} - -func normalizeCapabilityValue(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -func canonicalCapabilityOptionName(value string) string { - name := strings.TrimSpace(value) - switch name { - case "duration": - return "videoSeconds" - case "aspectRatio": - return "size" - case "resolution": - return "vquality" - default: - return name - } -} - -func isCapabilityOptionFor(capability string, name string) bool { - switch normalizeCapability(capability) { - case "image": - return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" - case "video": - return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" - case "audio": - return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions" - case "text": - // systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。 - return false - default: - return false - } -} - -func isProviderCapabilityOption(name string) bool { - return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name) -} - func (s *Service) invalidateRouteCatalog() { s.routeCatalogRefreshMu.Lock() s.routeCatalogMu.Lock() @@ -947,484 +480,3 @@ func (s *Service) sortedRouteDiagnostics(routes []cachedLogicalRoute, intent Mod sort.SliceStable(result, func(i, j int) bool { return result[i].Priority > result[j].Priority }) return result } - -func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, attemptNumber int) (*model.RouteAttempt, error) { - id, err := s.repo.NextPrefixedID("ATTEMPT") - if err != nil { - return nil, err - } - channelModel, err := s.repo.ChannelModelByID(routed.ChannelModel.ChannelID, routed.ChannelModel.ID) - if err != nil { - return nil, err - } - attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} - if err := s.repo.CreateRouteAttempt(attempt); err != nil { - return nil, err - } - return attempt, nil -} - -func (s *Service) beginTaskRouteAttempt(task *model.Task) (*model.RouteAttempt, error) { - if task == nil { - return nil, nil - } - attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) - if err != nil { - return nil, err - } - if len(attempts) > 0 { - existing := &attempts[len(attempts)-1] - switch existing.DispatchState { - case "not_sent": - return existing, nil - case "accepted": - if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { - if task.ProviderRequestID == "" { - task.ProviderRequestID = existing.ProviderRequestID - if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { - return nil, err - } - } - return existing, nil - } - return nil, routeDispatchUncertainError{"上游已接受请求,但没有可恢复的任务 ID"} - case "submission_unknown": - if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { - if task.ProviderRequestID == "" { - task.ProviderRequestID = existing.ProviderRequestID - if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { - return nil, err - } - } - existing.DispatchState = "accepted" - if err := s.repo.SaveRouteAttempt(existing); err != nil { - return nil, err - } - return existing, nil - } - return nil, routeDispatchUncertainError{"上一次提交结果不明确,为避免重复扣费已停止自动重发"} - case "rejected_no_job": - if task.LogicalModelID == "" { - return nil, errors.New("上游已拒绝本次请求,请检查渠道配置后再试") - } - return s.switchTaskToNextRoute(task, attempts) - } - } - if task.LogicalModelID == "" { - return s.createDirectTaskAttempt(task) - } - routed, routeErr := s.routedModelForTaskSelection(task) - if routeErr != nil { - return s.switchTaskToNextRoute(task, attempts) - } - return s.createRouteAttempt(task, routed, len(attempts)+1) -} - -func (s *Service) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { - if attempt == nil || attempt.DispatchState != "not_sent" { - return nil - } - // A stale worker must not dispatch the same selected attempt a second time. - if err := s.repo.MarkRouteAttemptDispatching(attempt.ID); err != nil { - return routeDispatchUncertainError{"提交状态未能独占确认,为避免重复扣费已停止自动重发"} - } - attempt.Status, attempt.DispatchState = "dispatching", "submission_unknown" - return nil -} - -type routeDispatchUncertainError struct{ message string } - -func (e routeDispatchUncertainError) Error() string { return e.message } - -func isRouteDispatchUncertain(err error) bool { - var target routeDispatchUncertainError - return errors.As(err, &target) -} - -func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, error) { - route, err := s.repo.LogicalModelRoute(task.RouteID) - if err != nil { - return nil, err - } - if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { - return nil, errors.New("任务使用的模型服务配置已失效") - } - if route.ChannelModelID != task.ChannelModelID { - return nil, errors.New("任务使用的模型服务配置已失效") - } - channelModel, err := s.repo.ChannelModel(task.ChannelModelID) - if err != nil { - return nil, err - } - if !channelModel.Enabled { - return nil, errors.New("任务使用的模型服务配置已失效") - } - if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { - return nil, err - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil { - return nil, err - } - if revision.LogicalModelID != logicalModel.ID { - return nil, errors.New("任务前台模型版本不一致") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, errors.New("当前模型服务暂不可用") - } - if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { - return nil, errors.New("任务使用的模型服务价格配置已失效") - } - if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { - return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") - } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} - return routed, nil -} - -func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.RouteAttempt) (*model.RouteAttempt, error) { - decrypted, err := s.decryptTaskInputJSON(task.InputJSON) - if err != nil { - return nil, err - } - var input map[string]any - if err := json.Unmarshal([]byte(decrypted), &input); err != nil { - return nil, BadAuthRequest("任务输入格式无效,无法恢复模型服务") - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil || revision.LogicalModelID != logicalModel.ID { - return nil, errors.New("任务前台模型版本不存在或归属不一致") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } - routes, err := s.repo.LogicalModelRoutes(revision.ID, false) - if err != nil { - return nil, err - } - channelModelIDs := make([]string, 0, len(routes)) - for _, route := range routes { - channelModelIDs = append(channelModelIDs, route.ChannelModelID) - } - channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) - if err != nil { - return nil, err - } - channelIDs := make([]string, 0, len(channelModels)) - for _, channelModel := range channelModels { - channelIDs = append(channelIDs, channelModel.ChannelID) - } - systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) - if err != nil { - return nil, err - } - enabledSystemChannels := make(map[string]bool, len(systemChannels)) - for _, channel := range systemChannels { - enabledSystemChannels[channel.ID] = true - } - channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) - for _, channelModel := range channelModels { - if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { - channelModelByID[channelModel.ID] = channelModel - } - } - candidates := make([]cachedLogicalRoute, 0, len(routes)) - for _, route := range routes { - channelModel, channelOK := channelModelByID[route.ChannelModelID] - if !channelOK { - continue - } - capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) - if specErr != nil { - continue - } - candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) - } - tried := make(map[string]bool, len(attempts)) - for _, attempt := range attempts { - tried[attempt.RouteID] = true - } - eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") - if len(eligible) == 0 { - return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") - } - selected := weightedRoute(eligible) - var priceTier *model.ChannelModelPriceTier - if logicalModel.PricePolicy == "channel" { - priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) - if priceTier == nil { - return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") - } - } - routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} - nextInput := applyRoutedProviderSelection(input, routed) - if err := s.ValidateTaskCapability(nextInput); err != nil { - return nil, err - } - if err := s.protectTaskSecrets(nextInput); err != nil { - return nil, err - } - encoded, err := json.Marshal(nextInput) - if err != nil { - return nil, err - } - var replacement *model.BillingOrder - if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { - config, _ := nextInput["config"].(map[string]any) - capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) - if capability == "" { - capability = capabilityFromTaskType(task.Type) - } - priceTierID, _ := config["priceTierId"].(string) - replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) - if err != nil { - return nil, err - } - replacement.Model = logicalModel.Code - } - previousRouteID := task.RouteID - var costOrder model.BillingOrder - if replacement != nil { - costOrder.BillingCostSnapshot = replacement.BillingCostSnapshot - } else if task.BillingOrderID != "" { - config, _ := nextInput["config"].(map[string]any) - capability := selected.ChannelModel.Capability - snapshotCreditCost(&costOrder, channelModelPriceTierForIntent(selected.ChannelModel, intent), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability)) - } - if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement, costOrder.BillingCostSnapshot); err != nil { - if errors.Is(err, repository.ErrInsufficientCredits) { - return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") - } - if errors.Is(err, repository.ErrBillingChargeLimit) { - return nil, creationConflict("备用线路报价超过已批准费用上限,未切换线路;请重新确认生成报价") - } - return nil, err - } - task.RouteID = selected.Route.ID - task.ChannelModelID = selected.ChannelModel.ID - task.InputJSON = string(encoded) - task.ProviderRequestID = "" - return s.createRouteAttempt(task, routed, len(attempts)+1) -} - -func (s *Service) nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) { - if task == nil || task.LogicalModelID == "" || attempt == nil || attempt.DispatchState != "rejected_no_job" { - return nil, nil - } - if errors.Is(taskErr, context.Canceled) || errors.Is(taskErr, context.DeadlineExceeded) { - return nil, nil - } - s.blockLogicalRouteForFailure(attempt, taskErr) - attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) - if err != nil { - return nil, err - } - return s.switchTaskToNextRoute(task, attempts) -} - -func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskErr error) { - if attempt == nil { - return - } - key := "" - duration := time.Duration(0) - if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { - key, duration = "channel:"+attempt.ChannelID, 10*time.Minute - } else if attempt.FailureCode == "upstream_404" { - key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute - } else if attempt.FailureCode == "upstream_429" { - key, duration = "channel:"+attempt.ChannelID, 30*time.Second - var upstream providerHTTPError - if errors.As(taskErr, &upstream) && upstream.RetryAfter > 0 { - duration = upstream.RetryAfter - } - } - if key == "" || duration <= 0 { - return - } - until := time.Now().Add(duration) - s.routeHealthMu.Lock() - // 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。 - s.routeHealthBlocked[key] = until - s.routeHealthMu.Unlock() - if s.coordinator != nil && s.coordinator.HasRedis() { - ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - if err := s.coordinator.BlockRoute(ctx, key, until); err != nil { - log.Printf("logical route distributed health update failed key=%s: %v", key, err) - } - } -} - -func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any) error { - if task == nil || task.LogicalModelID == "" { - return nil - } - intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return err - } - var routed *RoutedModel - if logicalModel.ArchivedAt != nil { - // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 - routed, err = s.resolveArchivedTaskRoute(task, intent) - } else { - routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) - } - if err != nil { - return err - } - input = applyRoutedProviderSelection(input, routed) - if err := s.ValidateTaskCapability(input); err != nil { - return err - } - if err := s.protectTaskSecrets(input); err != nil { - return err - } - encoded, err := json.Marshal(input) - if err != nil { - return err - } - task.LogicalModelRevisionID = routed.Revision.ID - task.RouteID = routed.Route.ID - task.ChannelModelID = routed.ChannelModel.ID - task.Model = routed.LogicalModel.Code - task.Provider = "managed" - task.InputJSON = string(encoded) - return nil -} - -// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 -// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 -func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { - if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { - return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") - } - logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) - if err != nil { - return nil, err - } - if logicalModel.ArchivedAt == nil { - return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") - } - revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) - if err != nil || revision.LogicalModelID != logicalModel.ID { - return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") - } - route, err := s.repo.LogicalModelRoute(task.RouteID) - if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { - return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") - } - channelModel, err := s.repo.ChannelModel(task.ChannelModelID) - if err != nil || !channelModel.Enabled { - return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") - } - if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { - return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") - } - productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) - if err != nil { - return nil, err - } - defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) - if err != nil { - return nil, err - } - intent.Options = mergeIntentDefaults(intent.Options, defaults) - if match := MatchCapability(productSpec, intent); !match.Matched { - return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) - } - capabilitySpec, err := channelModelCapabilitySpec(*channelModel) - if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { - return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") - } - if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { - return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") - } - return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil -} - -func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { - if attempt == nil { - return - } - now := time.Now() - attempt.CompletedAt = &now - if task != nil { - attempt.ProviderRequestID = task.ProviderRequestID - } - if taskErr == nil { - attempt.Status = "succeeded" - attempt.DispatchState = "accepted" - } else { - attempt.Status = "failed" - attempt.FailureMessage = truncateRunes(taskFailureMessage(taskErr), 1000) - attempt.FailureCode = routeFailureCode(taskErr) - if attempt.ProviderRequestID != "" { - attempt.DispatchState = "accepted" - } else if safeRouteRejection(taskErr) { - attempt.DispatchState = "rejected_no_job" - } else { - attempt.DispatchState = "submission_unknown" - } - } - if err := s.repo.SaveRouteAttempt(attempt); err != nil { - log.Printf("route attempt terminal state save failed attempt_id=%s task_id=%s: %v", attempt.ID, attempt.TaskID, err) - } -} - -func routeFailureCode(err error) string { - if code, _ := ChannelSlotFailureDetails(err); code != "" { - return code - } - var upstream providerHTTPError - if errors.As(err, &upstream) { - return fmt.Sprintf("upstream_%d", upstream.StatusCode) - } - return "submission_unknown" -} - -func safeRouteRejection(err error) bool { - if err == nil { - return false - } - if code, _ := ChannelSlotFailureDetails(err); code != "" { - return true - } - var upstream providerHTTPError - if errors.As(err, &upstream) { - switch upstream.StatusCode { - case 401, 403, 404, 429: - return true - } - } - return false -} diff --git a/backend/internal/app/model_router_attempts.go b/backend/internal/app/model_router_attempts.go new file mode 100644 index 000000000..a4f4272fc --- /dev/null +++ b/backend/internal/app/model_router_attempts.go @@ -0,0 +1,501 @@ +// 任务路由尝试(route attempt)的生命周期:创建、派发、失败切换、重试与收尾。 +// +// 一个任务可能依次尝试多条路由:上游返回可重试错误时切到下一条,并把失败路由短暂熔断 +// (blockLogicalRouteForFailure)。派发结果不确定(请求已发出但没拿到响应)时绝不切换, +// 避免同一请求在两个供应商处各扣一次费。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + "time" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/repository" +) + +func (s *Service) createRouteAttempt(task *model.Task, routed *RoutedModel, attemptNumber int) (*model.RouteAttempt, error) { + id, err := s.repo.NextPrefixedID("ATTEMPT") + if err != nil { + return nil, err + } + channelModel, err := s.repo.ChannelModelByID(routed.ChannelModel.ChannelID, routed.ChannelModel.ID) + if err != nil { + return nil, err + } + attempt := &model.RouteAttempt{ID: id, TaskID: task.ID, RouteRun: task.RouteRun, AttemptNumber: attemptNumber, LogicalModelID: routed.LogicalModel.ID, LogicalModelRevisionID: routed.Revision.ID, RouteID: routed.Route.ID, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, Status: "selected", DispatchState: "not_sent", StartedAt: time.Now()} + if err := s.repo.CreateRouteAttempt(attempt); err != nil { + return nil, err + } + return attempt, nil +} + +func (s *Service) beginTaskRouteAttempt(task *model.Task) (*model.RouteAttempt, error) { + if task == nil { + return nil, nil + } + attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) + if err != nil { + return nil, err + } + if len(attempts) > 0 { + existing := &attempts[len(attempts)-1] + switch existing.DispatchState { + case "not_sent": + return existing, nil + case "accepted": + if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { + if task.ProviderRequestID == "" { + task.ProviderRequestID = existing.ProviderRequestID + if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { + return nil, err + } + } + return existing, nil + } + return nil, routeDispatchUncertainError{"上游已接受请求,但没有可恢复的任务 ID"} + case "submission_unknown": + if existing.ProviderRequestID != "" || task.ProviderRequestID != "" { + if task.ProviderRequestID == "" { + task.ProviderRequestID = existing.ProviderRequestID + if err := s.repo.UpdateTaskProviderState(task.ID, task.ProviderRequestID, task.PollStage, task.NextPollAt); err != nil { + return nil, err + } + } + existing.DispatchState = "accepted" + if err := s.repo.SaveRouteAttempt(existing); err != nil { + return nil, err + } + return existing, nil + } + return nil, routeDispatchUncertainError{"上一次提交结果不明确,为避免重复扣费已停止自动重发"} + case "rejected_no_job": + if task.LogicalModelID == "" { + return nil, errors.New("上游已拒绝本次请求,请检查渠道配置后再试") + } + return s.switchTaskToNextRoute(task, attempts) + } + } + if task.LogicalModelID == "" { + return s.createDirectTaskAttempt(task) + } + routed, routeErr := s.routedModelForTaskSelection(task) + if routeErr != nil { + return s.switchTaskToNextRoute(task, attempts) + } + return s.createRouteAttempt(task, routed, len(attempts)+1) +} + +func (s *Service) markRouteAttemptDispatching(attempt *model.RouteAttempt) error { + if attempt == nil || attempt.DispatchState != "not_sent" { + return nil + } + // A stale worker must not dispatch the same selected attempt a second time. + if err := s.repo.MarkRouteAttemptDispatching(attempt.ID); err != nil { + return routeDispatchUncertainError{"提交状态未能独占确认,为避免重复扣费已停止自动重发"} + } + attempt.Status, attempt.DispatchState = "dispatching", "submission_unknown" + return nil +} + +type routeDispatchUncertainError struct{ message string } + +func (e routeDispatchUncertainError) Error() string { return e.message } + +func isRouteDispatchUncertain(err error) bool { + var target routeDispatchUncertainError + return errors.As(err, &target) +} + +func (s *Service) routedModelForTaskSelection(task *model.Task) (*RoutedModel, error) { + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil { + return nil, err + } + if !route.Enabled || route.Weight <= 0 || route.LogicalModelRevisionID != task.LogicalModelRevisionID { + return nil, errors.New("任务使用的模型服务配置已失效") + } + if route.ChannelModelID != task.ChannelModelID { + return nil, errors.New("任务使用的模型服务配置已失效") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil { + return nil, err + } + if !channelModel.Enabled { + return nil, errors.New("任务使用的模型服务配置已失效") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, err + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil { + return nil, err + } + if revision.LogicalModelID != logicalModel.ID { + return nil, errors.New("任务前台模型版本不一致") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, errors.New("当前模型服务暂不可用") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, errors.New("任务使用的模型服务价格配置已失效") + } + if logicalModel.PricePolicy == "unified" && logicalModel.BillingMode == "token" && !supportsTokenBilling(logicalModel.Capability, channelModel.Protocol) { + return nil, errors.New("任务使用的模型服务不再支持当前 Token 计费配置") + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults} + return routed, nil +} + +func (s *Service) switchTaskToNextRoute(task *model.Task, attempts []model.RouteAttempt) (*model.RouteAttempt, error) { + decrypted, err := s.decryptTaskInputJSON(task.InputJSON) + if err != nil { + return nil, err + } + var input map[string]any + if err := json.Unmarshal([]byte(decrypted), &input); err != nil { + return nil, BadAuthRequest("任务输入格式无效,无法恢复模型服务") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, errors.New("任务前台模型版本不存在或归属不一致") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("任务参数不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + routes, err := s.repo.LogicalModelRoutes(revision.ID, false) + if err != nil { + return nil, err + } + channelModelIDs := make([]string, 0, len(routes)) + for _, route := range routes { + channelModelIDs = append(channelModelIDs, route.ChannelModelID) + } + channelModels, err := s.repo.ChannelModelsByIDs(channelModelIDs) + if err != nil { + return nil, err + } + channelIDs := make([]string, 0, len(channelModels)) + for _, channelModel := range channelModels { + channelIDs = append(channelIDs, channelModel.ChannelID) + } + systemChannels, err := s.repo.SystemChannelsByIDs(channelIDs, false) + if err != nil { + return nil, err + } + enabledSystemChannels := make(map[string]bool, len(systemChannels)) + for _, channel := range systemChannels { + enabledSystemChannels[channel.ID] = true + } + channelModelByID := make(map[string]model.ChannelModel, len(channelModels)) + for _, channelModel := range channelModels { + if channelModel.Enabled && enabledSystemChannels[channelModel.ChannelID] && (logicalModel.PricePolicy != "channel" || channelModelHasActivePriceTier(channelModel)) { + channelModelByID[channelModel.ID] = channelModel + } + } + candidates := make([]cachedLogicalRoute, 0, len(routes)) + for _, route := range routes { + channelModel, channelOK := channelModelByID[route.ChannelModelID] + if !channelOK { + continue + } + capabilitySpec, specErr := channelModelCapabilitySpec(channelModel) + if specErr != nil { + continue + } + candidates = append(candidates, cachedLogicalRoute{Route: route, CapabilitySpec: capabilitySpec, ChannelModel: channelModel}) + } + tried := make(map[string]bool, len(attempts)) + for _, attempt := range attempts { + tried[attempt.RouteID] = true + } + eligible := s.eligibleLogicalRoutes(candidates, intent, tried, logicalModel.PricePolicy == "channel") + if len(eligible) == 0 { + return nil, BadAuthRequest("当前模型暂时无法满足这组输入和参数") + } + selected := weightedRoute(eligible) + var priceTier *model.ChannelModelPriceTier + if logicalModel.PricePolicy == "channel" { + priceTier = channelModelPriceTierForIntent(selected.ChannelModel, intent) + if priceTier == nil { + return nil, BadAuthRequest("当前模型尚未配置所选规格的价格") + } + } + routed := &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: selected.Route, ChannelModel: selected.ChannelModel, PriceTier: priceTier, Defaults: defaults} + nextInput := applyRoutedProviderSelection(input, routed) + if err := s.ValidateTaskCapability(nextInput); err != nil { + return nil, err + } + if err := s.protectTaskSecrets(nextInput); err != nil { + return nil, err + } + encoded, err := json.Marshal(nextInput) + if err != nil { + return nil, err + } + var replacement *model.BillingOrder + if logicalModel.PricePolicy == "channel" && task.BillingOrderID != "" { + config, _ := nextInput["config"].(map[string]any) + capability := normalizeCapability(fmt.Sprint(nextInput["mode"])) + if capability == "" { + capability = capabilityFromTaskType(task.Type) + } + priceTierID, _ := config["priceTierId"].(string) + replacement, err = s.newBillingOrderWithPriceTier(task.UserID, task.ID, "route-switch:"+task.ID+":"+selected.Route.ID, selected.ChannelModel.ChannelID, selected.ChannelModel.ModelKey, capability, firstNonEmpty(strings.TrimSpace(task.Operation), task.Type), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability), strings.TrimSpace(priceTierID), intent) + if err != nil { + return nil, err + } + replacement.Model = logicalModel.Code + } + previousRouteID := task.RouteID + var costOrder model.BillingOrder + if replacement != nil { + costOrder.BillingCostSnapshot = replacement.BillingCostSnapshot + } else if task.BillingOrderID != "" { + config, _ := nextInput["config"].(map[string]any) + capability := selected.ChannelModel.Capability + snapshotCreditCost(&costOrder, channelModelPriceTierForIntent(selected.ChannelModel, intent), billingQuantity(capability, config["videoSeconds"]), estimateTaskBillingTokens(nextInput, capability)) + } + if err := s.repo.SwitchTaskLogicalRoute(task.ID, previousRouteID, selected.Route.ID, string(encoded), task.BillingOrderID, selected.ChannelModel.ChannelID, selected.ChannelModel.ID, replacement, costOrder.BillingCostSnapshot); err != nil { + if errors.Is(err, repository.ErrInsufficientCredits) { + return nil, BadAuthRequest("模型服务价格发生变化,当前积分余额不足") + } + if errors.Is(err, repository.ErrBillingChargeLimit) { + return nil, creationConflict("备用线路报价超过已批准费用上限,未切换线路;请重新确认生成报价") + } + return nil, err + } + task.RouteID = selected.Route.ID + task.ChannelModelID = selected.ChannelModel.ID + task.InputJSON = string(encoded) + task.ProviderRequestID = "" + return s.createRouteAttempt(task, routed, len(attempts)+1) +} + +func (s *Service) nextRouteAttemptAfterFailure(task *model.Task, attempt *model.RouteAttempt, taskErr error) (*model.RouteAttempt, error) { + if task == nil || task.LogicalModelID == "" || attempt == nil || attempt.DispatchState != "rejected_no_job" { + return nil, nil + } + if errors.Is(taskErr, context.Canceled) || errors.Is(taskErr, context.DeadlineExceeded) { + return nil, nil + } + s.blockLogicalRouteForFailure(attempt, taskErr) + attempts, err := s.repo.RouteAttempts(task.ID, task.RouteRun) + if err != nil { + return nil, err + } + return s.switchTaskToNextRoute(task, attempts) +} + +func (s *Service) blockLogicalRouteForFailure(attempt *model.RouteAttempt, taskErr error) { + if attempt == nil { + return + } + key := "" + duration := time.Duration(0) + if attempt.FailureCode == "upstream_401" || attempt.FailureCode == "upstream_403" { + key, duration = "channel:"+attempt.ChannelID, 10*time.Minute + } else if attempt.FailureCode == "upstream_404" { + key, duration = "channel-model:"+attempt.ChannelModelID, 10*time.Minute + } else if attempt.FailureCode == "upstream_429" { + key, duration = "channel:"+attempt.ChannelID, 30*time.Second + var upstream providerHTTPError + if errors.As(taskErr, &upstream) && upstream.RetryAfter > 0 { + duration = upstream.RetryAfter + } + } + if key == "" || duration <= 0 { + return + } + until := time.Now().Add(duration) + s.routeHealthMu.Lock() + // 本地状态是 Redis 不可用时的降级,也能覆盖 Redis 写入瞬间的网络抖动。 + s.routeHealthBlocked[key] = until + s.routeHealthMu.Unlock() + if s.coordinator != nil && s.coordinator.HasRedis() { + ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) + defer cancel() + if err := s.coordinator.BlockRoute(ctx, key, until); err != nil { + log.Printf("logical route distributed health update failed key=%s: %v", key, err) + } + } +} + +func (s *Service) prepareLogicalTaskRetry(task *model.Task, input map[string]any) error { + if task == nil || task.LogicalModelID == "" { + return nil + } + intent := ModelRequestIntentFromTaskInput(input, task.Type, task.Operation) + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return err + } + var routed *RoutedModel + if logicalModel.ArchivedAt != nil { + // 归档只隐藏新任务的公开目录;历史任务重试必须使用任务快照,不能重新从公开目录选路。 + routed, err = s.resolveArchivedTaskRoute(task, intent) + } else { + routed, err = s.ResolveLogicalModel(task.LogicalModelID, intent) + } + if err != nil { + return err + } + input = applyRoutedProviderSelection(input, routed) + if err := s.ValidateTaskCapability(input); err != nil { + return err + } + if err := s.protectTaskSecrets(input); err != nil { + return err + } + encoded, err := json.Marshal(input) + if err != nil { + return err + } + task.LogicalModelRevisionID = routed.Revision.ID + task.RouteID = routed.Route.ID + task.ChannelModelID = routed.ChannelModel.ID + task.Model = routed.LogicalModel.Code + task.Provider = "managed" + task.InputJSON = string(encoded) + return nil +} + +// resolveArchivedTaskRoute 恢复归档模型任务保存的 revision、route 和 channel model 快照。 +// 归档模型不得重新进入公开路由目录;原供应线路失效时应明确拒绝重试,避免静默切换到未知配置。 +func (s *Service) resolveArchivedTaskRoute(task *model.Task, intent ModelRequestIntent) (*RoutedModel, error) { + if task == nil || task.LogicalModelID == "" || task.LogicalModelRevisionID == "" || task.RouteID == "" || task.ChannelModelID == "" { + return nil, BadAuthRequest("历史任务缺少完整的模型服务快照,无法重试") + } + logicalModel, err := s.repo.LogicalModel(task.LogicalModelID) + if err != nil { + return nil, err + } + if logicalModel.ArchivedAt == nil { + return nil, BadAuthRequest("任务模型已恢复为可用模型,请重新选择后重试") + } + revision, err := s.repo.LogicalModelRevision(task.LogicalModelRevisionID) + if err != nil || revision.LogicalModelID != logicalModel.ID { + return nil, BadAuthRequest("历史任务前台模型版本不存在或归属不一致") + } + route, err := s.repo.LogicalModelRoute(task.RouteID) + if err != nil || route.LogicalModelRevisionID != revision.ID || route.ChannelModelID != task.ChannelModelID || !route.Enabled || route.Weight <= 0 { + return nil, BadAuthRequest("历史任务原模型供应线路已失效,无法重试") + } + channelModel, err := s.repo.ChannelModel(task.ChannelModelID) + if err != nil || !channelModel.Enabled { + return nil, BadAuthRequest("历史任务原模型服务已失效,无法重试") + } + if _, err := s.repo.SystemChannel(channelModel.ChannelID); err != nil { + return nil, BadAuthRequest("历史任务原模型渠道已失效,无法重试") + } + productSpec, err := DecodeCapabilitySpec(revision.CapabilitySpecJSON) + if err != nil { + return nil, err + } + defaults, err := decodeLogicalDefaults(revision.DefaultOptionsJSON, productSpec) + if err != nil { + return nil, err + } + intent.Options = mergeIntentDefaults(intent.Options, defaults) + if match := MatchCapability(productSpec, intent); !match.Matched { + return nil, BadAuthRequest("历史任务不再符合前台模型能力:" + strings.Join(match.Reasons, ";")) + } + capabilitySpec, err := channelModelCapabilitySpec(*channelModel) + if err != nil || s.logicalRouteBlocked(cachedLogicalRoute{Route: *route, CapabilitySpec: capabilitySpec, ChannelModel: *channelModel}) { + return nil, BadAuthRequest("历史任务原模型供应线路暂不可用,无法重试") + } + if logicalModel.PricePolicy == "channel" && !channelModel.PriceConfigured { + return nil, BadAuthRequest("历史任务原模型价格配置已失效,无法重试") + } + return &RoutedModel{LogicalModel: *logicalModel, Revision: *revision, Route: *route, ChannelModel: *channelModel, Defaults: defaults}, nil +} + +func (s *Service) finishTaskRouteAttempt(attempt *model.RouteAttempt, task *model.Task, taskErr error) { + if attempt == nil { + return + } + now := time.Now() + attempt.CompletedAt = &now + if task != nil { + attempt.ProviderRequestID = task.ProviderRequestID + } + if taskErr == nil { + attempt.Status = "succeeded" + attempt.DispatchState = "accepted" + } else { + attempt.Status = "failed" + attempt.FailureMessage = truncateRunes(taskFailureMessage(taskErr), 1000) + attempt.FailureCode = routeFailureCode(taskErr) + if attempt.ProviderRequestID != "" { + attempt.DispatchState = "accepted" + } else if safeRouteRejection(taskErr) { + attempt.DispatchState = "rejected_no_job" + } else { + attempt.DispatchState = "submission_unknown" + } + } + if err := s.repo.SaveRouteAttempt(attempt); err != nil { + log.Printf("route attempt terminal state save failed attempt_id=%s task_id=%s: %v", attempt.ID, attempt.TaskID, err) + } +} + +func routeFailureCode(err error) string { + if code, _ := ChannelSlotFailureDetails(err); code != "" { + return code + } + var upstream providerHTTPError + if errors.As(err, &upstream) { + return fmt.Sprintf("upstream_%d", upstream.StatusCode) + } + return "submission_unknown" +} + +func safeRouteRejection(err error) bool { + if err == nil { + return false + } + if code, _ := ChannelSlotFailureDetails(err); code != "" { + return true + } + var upstream providerHTTPError + if errors.As(err, &upstream) { + switch upstream.StatusCode { + case 401, 403, 404, 429: + return true + } + } + return false +} diff --git a/backend/internal/app/model_router_capability.go b/backend/internal/app/model_router_capability.go new file mode 100644 index 000000000..ed6b1ef16 --- /dev/null +++ b/backend/internal/app/model_router_capability.go @@ -0,0 +1,482 @@ +// 模型能力规格(CapabilitySpec)的解码、规范化与匹配。 +// +// 一次生成请求先被归一成 ModelRequestIntent(模式、尺寸、时长、参考图数量等), +// 再逐个路由比对能力规格:输入约束(参考图/视频/音频数量)与选项约束(尺寸、比例、质量) +// 都满足才算可用。这里是纯函数,不读数据库。 + +package app + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +type CapabilityImageSizePreset struct { + Size string `json:"size"` + Tier string `json:"tier"` + Ratio string `json:"ratio"` + Width int `json:"width"` + Height int `json:"height"` +} + +type CapabilityImageSize struct { + Parameter string `json:"parameter,omitempty"` + AllowCustom bool `json:"allowCustom,omitempty"` + Presets []CapabilityImageSizePreset `json:"presets,omitempty"` +} + +type CapabilitySpec struct { + Version int `json:"version"` + Capability string `json:"capability"` + Operations []string `json:"operations,omitempty"` + Inputs map[string]InputConstraint `json:"inputs,omitempty"` + Options map[string]OptionConstraint `json:"options,omitempty"` + ImageSize *CapabilityImageSize `json:"imageSize,omitempty"` +} + +type InputConstraint struct { + Min int `json:"min"` + Max int `json:"max"` +} + +type OptionConstraint struct { + Values []any `json:"values,omitempty"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Step *float64 `json:"step,omitempty"` +} + +type ModelRequestIntent struct { + Capability string `json:"capability"` + Operation string `json:"operation,omitempty"` + Inputs map[string]int `json:"inputs,omitempty"` + Options map[string]any `json:"options,omitempty"` +} + +// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。 +func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent { + capability := normalizeCapability(fmt.Sprint(input["mode"])) + if capability == "" { + capability = capabilityFromTaskType(taskType) + } + intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}} + for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} { + if values, ok := input[key].([]any); ok { + intent.Inputs[inputType] = len(values) + } + } + if mask, exists := input["mask"]; exists && mask != nil { + intent.Inputs["mask"] = 1 + } + explicitOptions := false + if options, ok := input["capabilityOptions"].(map[string]any); ok { + explicitOptions = true + for key, value := range options { + name := canonicalCapabilityOptionName(key) + // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 + // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 + if name == "quality" { + if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { + continue + } + } + intent.Options[name] = normalizeModelRequestOption(name, value) + } + } + if config, ok := input["config"].(map[string]any); ok && !explicitOptions { + for key, value := range config { + switch key { + case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig": + continue + default: + canonical := canonicalCapabilityOptionName(key) + if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" { + intent.Options[canonical] = normalizeModelRequestOption(canonical, value) + } + } + } + } + return intent +} + +func normalizeModelRequestOption(name string, value any) any { + canonicalName := canonicalCapabilityOptionName(name) + if canonicalName == "quality" || canonicalName == "size" { + if text, ok := value.(string); ok { + return strings.ToLower(strings.TrimSpace(text)) + } + return value + } + if canonicalName != "vquality" { + return value + } + resolution, ok := value.(string) + if !ok { + return value + } + switch strings.ToLower(strings.TrimSpace(resolution)) { + case "low", "480", "480p": + return "480p" + case "720", "720p": + return "720p" + case "1080", "1080p": + return "1080p" + case "2k", "1440", "1440p": + return "1440p" + case "4k", "2160", "2160p": + return "2160p" + default: + // Providers expose additional resolutions such as 768P and 960P. + // Canonicalize numeric P values generically so billing selectors match + // regardless of whether the client sends "960" or "960P". + pixels := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(resolution)), "p") + if numeric, err := strconv.Atoi(pixels); err == nil && numeric > 0 { + return strconv.Itoa(numeric) + "p" + } + return value + } +} + +type CapabilityMatch struct { + Matched bool `json:"matched"` + Reasons []string `json:"reasons,omitempty"` +} + +type cachedLogicalModel struct { + Model model.LogicalModel + Revision model.LogicalModelRevision + ProductSpec CapabilitySpec + Defaults map[string]any + Routes []cachedLogicalRoute +} + +type cachedLogicalRoute struct { + Route model.LogicalModelRoute + CapabilitySpec CapabilitySpec + ChannelModel model.ChannelModel +} + +type routeCatalogSnapshot struct { + LoadedAt time.Time + CatalogVersion int64 + Models map[string]cachedLogicalModel + Ordered []string +} + +type RoutedModel struct { + LogicalModel model.LogicalModel + Revision model.LogicalModelRevision + Route model.LogicalModelRoute + ChannelModel model.ChannelModel + PriceTier *model.ChannelModelPriceTier + Defaults map[string]any +} + +func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) { + var spec CapabilitySpec + if err := json.Unmarshal([]byte(raw), &spec); err != nil { + return spec, BadAuthRequest("能力配置不是有效 JSON") + } + normalized, err := NormalizeCapabilitySpec(spec) + if err != nil { + return spec, err + } + return normalized, nil +} + +func ValidateCapabilitySpec(spec CapabilitySpec) error { + _, err := NormalizeCapabilitySpec(spec) + return err +} + +func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) { + spec.Capability = normalizeCapability(spec.Capability) + if spec.Version != 1 { + return spec, BadAuthRequest("能力配置 version 必须为 1") + } + if spec.Capability == "" { + return spec, BadAuthRequest("能力配置必须声明 capability") + } + operations := make([]string, 0, len(spec.Operations)) + seenOperations := make(map[string]bool, len(spec.Operations)) + for _, operation := range spec.Operations { + normalized := normalizeCapabilityValue(operation) + if normalized != "" && !seenOperations[normalized] { + seenOperations[normalized] = true + operations = append(operations, normalized) + } + } + spec.Operations = operations + normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs)) + for rawName, constraint := range spec.Inputs { + name := normalizeCapabilityValue(rawName) + if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min { + return spec, BadAuthRequest("输入能力范围无效") + } + if _, exists := normalizedInputs[name]; exists { + return spec, BadAuthRequest("输入能力存在重复名称:" + name) + } + normalizedInputs[name] = constraint + } + spec.Inputs = normalizedInputs + normalizedOptions := make(map[string]OptionConstraint, len(spec.Options)) + for rawName, constraint := range spec.Options { + name := canonicalCapabilityOptionName(rawName) + if strings.TrimSpace(name) == "" { + return spec, BadAuthRequest("能力参数名称不能为空") + } + if _, exists := normalizedOptions[name]; exists { + return spec, BadAuthRequest("能力参数存在重复别名:" + name) + } + hasValues := len(constraint.Values) > 0 + hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil + if !hasValues && !hasRange { + return spec, BadAuthRequest("能力参数必须声明 values 或数值范围") + } + if hasValues && hasRange { + return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围") + } + if hasRange && (constraint.Min == nil || constraint.Max == nil) { + return spec, BadAuthRequest("数值范围必须同时声明 min 和 max") + } + if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min { + return spec, BadAuthRequest("能力参数数值范围无效") + } + if constraint.Step != nil && *constraint.Step <= 0 { + return spec, BadAuthRequest("能力参数 step 必须大于 0") + } + normalizedOptions[name] = constraint + } + spec.Options = normalizedOptions + return spec, nil +} + +func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) { + defaults := map[string]any{} + if err := json.Unmarshal([]byte(raw), &defaults); err != nil { + return nil, err + } + return normalizeLogicalDefaults(spec, defaults) +} + +func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { + reasons := make([]string, 0) + if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { + reasons = append(reasons, "能力类型不匹配") + } + if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { + reasons = append(reasons, "不支持操作 "+intent.Operation) + } + for inputType, count := range intent.Inputs { + if count < 0 { + reasons = append(reasons, "输入数量不能小于 0") + continue + } + constraint, declared := spec.Inputs[inputType] + if !declared { + if count > 0 { + reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") + } + continue + } + if count < constraint.Min || count > constraint.Max { + reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) + } + } + for inputType, constraint := range spec.Inputs { + if intent.Inputs[inputType] < constraint.Min { + reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) + } + } + for name, value := range intent.Options { + constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] + if !declared { + reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) + continue + } + if !matchOptionConstraint(name, constraint, value) { + reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") + } + } + return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} +} + +func capabilityInputLabel(name string) string { + switch normalizeCapabilityValue(name) { + case "image": + return "参考图片" + case "video": + return "参考视频" + case "audio": + return "参考音频" + case "mask": + return "蒙版" + default: + return name + } +} + +func capabilityOptionLabel(name string) string { + switch canonicalCapabilityOptionName(name) { + case "size": + return "画面尺寸" + case "quality": + return "生成质量" + case "transparentBackground": + return "透明背景" + case "count": + return "输出数量" + case "videoSeconds": + return "视频时长" + case "vquality": + return "输出分辨率" + case "videoGenerateAudio": + return "同步音频" + case "videoWatermark": + return "水印设置" + case "audioVoice": + return "音色" + case "audioFormat": + return "音频格式" + case "audioSpeed": + return "语速" + case "audioInstructions": + return "朗读指令" + default: + return name + } +} + +func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool { + if len(constraint.Values) > 0 { + for _, candidate := range constraint.Values { + if normalizedScalar(candidate) == "*" { + return true + } + if capabilityOptionValuesEqual(name, candidate, value) { + return true + } + } + return false + } + number, ok := numericScalar(value) + if !ok { + return false + } + if constraint.Min != nil && number < *constraint.Min { + return false + } + if constraint.Max != nil && number > *constraint.Max { + return false + } + if constraint.Step != nil && constraint.Min != nil { + steps := (number - *constraint.Min) / *constraint.Step + return math.Abs(steps-math.Round(steps)) < 1e-9 + } + return true +} + +func capabilityOptionValuesEqual(name string, candidate any, value any) bool { + left := normalizedScalar(candidate) + right := normalizedScalar(value) + if canonicalCapabilityOptionName(name) == "vquality" { + // Compare using the same aliases as request intents and price tiers. + left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p") + right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p") + } + return left == right +} + +func normalizedScalar(value any) string { + switch typed := value.(type) { + case string: + return normalizeCapabilityValue(typed) + case bool: + return strconv.FormatBool(typed) + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(typed), 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + encoded, _ := json.Marshal(value) + return string(encoded) + } +} + +func numericScalar(value any) (float64, bool) { + switch typed := value.(type) { + case float64: + return typed, true + case float32: + return float64(typed), true + case int: + return float64(typed), true + case int64: + return float64(typed), true + case json.Number: + parsed, err := typed.Float64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64) + return parsed, err == nil + default: + return 0, false + } +} + +func containsNormalized(values []string, expected string) bool { + for _, value := range values { + if normalizeCapabilityValue(value) == expected { + return true + } + } + return false +} + +func normalizeCapabilityValue(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func canonicalCapabilityOptionName(value string) string { + name := strings.TrimSpace(value) + switch name { + case "duration": + return "videoSeconds" + case "aspectRatio": + return "size" + case "resolution": + return "vquality" + default: + return name + } +} + +func isCapabilityOptionFor(capability string, name string) bool { + switch normalizeCapability(capability) { + case "image": + return name == "size" || name == "quality" || name == "transparentBackground" || name == "count" + case "video": + return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark" + case "audio": + return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions" + case "text": + // systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。 + return false + default: + return false + } +} + +func isProviderCapabilityOption(name string) bool { + return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name) +} diff --git a/backend/internal/app/payment.go b/backend/internal/app/payment.go index af51d2b9c..28712bb79 100644 --- a/backend/internal/app/payment.go +++ b/backend/internal/app/payment.go @@ -6,20 +6,16 @@ import ( "encoding/hex" "encoding/json" "errors" - "fmt" - "log" - "math" "net/http" "net/url" "strings" "time" + "gorm.io/gorm" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/payment" "infinite-canvas/backend/internal/protocol" "infinite-canvas/backend/internal/repository" - - "gorm.io/gorm" ) const ( @@ -125,456 +121,6 @@ type AdminPaymentOrderView struct { User *AdminPaymentOrderUser `json:"user"` } -func (s *Service) PaymentNotificationResponse(providerID string, success bool) (int, string, string) { - return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, success, http.StatusInternalServerError) -} - -func (s *Service) PaymentNotificationFailureResponse(providerID string, status int) (int, string, string) { - return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, false, status) -} - -func PaymentNotificationResponseFor(providerID string, success bool, failureStatus int) (int, string, string) { - registry, _ := payment.NewRegistry() - return PaymentNotificationResponseForWithRegistry(registry, providerID, success, failureStatus) -} - -func PaymentNotificationResponseForWithRegistry(registry *payment.Registry, providerID string, success bool, failureStatus int) (int, string, string) { - if registry != nil { - if provider, ok := registry.Get(providerID); ok { - descriptor := provider.Descriptor() - response := descriptor.NotificationFailure - if success { - response = descriptor.NotificationSuccess - } - status := response.Status - if status == 0 { - status = failureStatus - } - contentType, body := response.ContentType, response.Body - if contentType == "" { - contentType = "text/plain; charset=utf-8" - } - return status, contentType, body - } - } - if success { - return http.StatusNoContent, "", "" - } - return failureStatus, "", "" -} - -func (s *Service) PaymentProviders(actor *model.User) ([]PaymentProviderView, error) { - if actor == nil { - return nil, Unauthorized("请先登录") - } - if err := s.RequireFeature(FeatureCredits); err != nil { - return nil, err - } - items := make([]PaymentProviderView, 0) - for _, descriptor := range s.paymentRegistry.Descriptors() { - view, _, err := s.paymentProviderView(descriptor) - if err != nil { - return nil, err - } - if view.Enabled && view.Configured { - items = append(items, view) - } - } - return items, nil -} - -func (s *Service) AdminPaymentProviders(actor *model.User) ([]AdminPaymentProviderView, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - items := make([]AdminPaymentProviderView, 0) - for _, descriptor := range s.paymentRegistry.Descriptors() { - base, config, err := s.paymentProviderView(descriptor) - if err != nil { - return nil, err - } - manifest, _ := s.paymentManifestForProvider(descriptor.ID) - view := AdminPaymentProviderView{PaymentProviderView: base, Values: map[string]string{}, SecretConfigured: map[string]bool{}, ConfigFields: manifest.Configuration.Fields} - if config != nil { - values, err := s.decryptPaymentConfig(config) - if err != nil { - return nil, err - } - view.ConfigID = config.ID - view.ConfigEnabled = config.Enabled - view.Version = config.Version - view.UpdatedAt = &config.CreatedAt - for _, field := range manifest.Configuration.Fields { - if field.Secret { - view.SecretConfigured[field.Name] = strings.TrimSpace(values[field.Name]) != "" - continue - } - view.Values[field.Name] = values[field.Name] - } - } - items = append(items, view) - } - return items, nil -} - -func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID string, request UpdatePaymentProviderConfigRequest) (*AdminPaymentProviderView, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - provider, ok := s.paymentRegistry.Get(providerID) - if !ok { - return nil, BadAuthRequest("未知支付渠道") - } - descriptor := provider.Descriptor() - manifest, ok := s.paymentManifestForProvider(descriptor.ID) - if !ok { - return nil, BadAuthRequest("支付插件清单不存在") - } - policy, ok := paymentExpiryPolicy(manifest, descriptor.ID) - if !ok { - return nil, BadAuthRequest("支付插件清单不存在") - } - if request.CloseAfterMinutes < policy.MinMinutes || request.CloseAfterMinutes > policy.MaxMinutes { - return nil, BadAuthRequest(fmt.Sprintf("未支付关闭时间必须为 %d-%d 分钟", policy.MinMinutes, policy.MaxMinutes)) - } - values := make(payment.Config) - previousIdentity := make(map[string]string) - current, err := s.repo.LatestPaymentProviderConfig(providerID) - if err == nil { - values, err = s.decryptPaymentConfig(current) - if err != nil { - return nil, err - } - for _, field := range descriptor.IdentityFields { - previousIdentity[field] = strings.TrimSpace(values[field]) - } - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - for _, field := range manifest.Configuration.Fields { - value, supplied := request.Values[field.Name] - value = strings.TrimSpace(value) - if field.Secret && (!supplied || value == "") { - continue - } - if !supplied && field.Default != nil && strings.TrimSpace(values[field.Name]) == "" { - value = strings.TrimSpace(fmt.Sprint(field.Default)) - } - values[field.Name] = value - } - changedIdentityField := "" - for field, previous := range previousIdentity { - if previous != "" && strings.TrimSpace(values[field]) != previous { - changedIdentityField = field - break - } - } - if changedIdentityField != "" { - orderCount, err := s.repo.PaymentOrderCountForProvider(providerID) - if err != nil { - return nil, err - } - if orderCount > 0 { - return nil, BadAuthRequest("该渠道已有历史订单,首期不支持切换商户身份;请保持 " + changedIdentityField + " 不变") - } - } - if base := values["publicBaseUrl"]; base != "" { - if err := validatePaymentPublicBaseURL(base); err != nil { - return nil, BadAuthRequest(err.Error()) - } - } - if request.Enabled { - if strings.TrimSpace(values["publicBaseUrl"]) == "" { - return nil, BadAuthRequest("启用支付渠道前必须填写服务器公网地址") - } - if err := provider.ValidateConfig(values); err != nil { - return nil, BadAuthRequest(err.Error()) - } - } - plain, err := json.Marshal(values) - if err != nil { - return nil, err - } - ciphertext, err := s.encryptSettingSecret(string(plain)) - if err != nil { - return nil, err - } - digest := sha256.Sum256(plain) - config := &model.PaymentProviderConfig{ - ID: newID(), ProviderID: descriptor.ID, PluginID: descriptor.PluginID, PluginVersion: descriptor.PluginVersion, - Enabled: request.Enabled, CloseAfterMinutes: request.CloseAfterMinutes, - ConfigCipher: ciphertext, ConfigDigest: hex.EncodeToString(digest[:]), CreatedBy: actor.ID, - } - if err := s.repo.CreatePaymentProviderConfig(config); err != nil { - return nil, err - } - if err := s.appendAdminAudit(actor, "payment_provider.config.update", "payment_provider", providerID, "更新支付渠道配置", map[string]any{ - "version": config.Version, "enabled": config.Enabled, "closeAfterMinutes": config.CloseAfterMinutes, - }); err != nil { - return nil, err - } - items, err := s.AdminPaymentProviders(actor) - if err != nil { - return nil, err - } - for index := range items { - if items[index].ID == providerID { - return &items[index], nil - } - } - return nil, errors.New("保存支付渠道配置后未找到渠道") -} - -func (s *Service) paymentProviderView(descriptor payment.Descriptor) (PaymentProviderView, *model.PaymentProviderConfig, error) { - view := PaymentProviderView{ID: descriptor.ID, PluginID: descriptor.PluginID, Name: descriptor.Name, Icon: descriptor.Icon, CheckoutMode: descriptor.CheckoutMode} - if manifest, ok := s.paymentManifestForProvider(descriptor.ID); ok { - if policy, found := paymentExpiryPolicy(manifest, descriptor.ID); found { - view.CloseAfterMinutes = policy.DefaultMinutes - } - } - state, err := s.pluginStateForUser(nil, descriptor.PluginID, s.Plugins()) - if err == nil { - view.PluginEnabled = state.PlatformAvailable - view.Enabled = state.PlatformAvailable - } - config, configErr := s.repo.LatestPaymentProviderConfig(descriptor.ID) - if errors.Is(configErr, gorm.ErrRecordNotFound) { - return view, nil, nil - } - if configErr != nil { - return view, nil, configErr - } - view.Configured = strings.TrimSpace(config.ConfigCipher) != "" - view.Enabled = view.Enabled && config.Enabled - view.CloseAfterMinutes = config.CloseAfterMinutes - return view, config, nil -} - -func (s *Service) paymentManifestForProvider(providerID string) (protocol.Manifest, bool) { - providerID = strings.TrimSpace(providerID) - if providerID == "" { - return protocol.Manifest{}, false - } - if s != nil { - for _, plugin := range s.Plugins() { - for _, contribution := range plugin.Manifest.Contributes.PaymentProviders { - if contribution.ID == providerID { - return protocolManifestFromPluginView(plugin), true - } - } - } - } - return bundledPaymentManifestForProvider(providerID) -} - -func bundledPaymentManifestForProvider(providerID string) (protocol.Manifest, bool) { - for _, manifest := range bundledPaymentPluginManifests() { - for _, contribution := range manifest.Contributes.PaymentProviders { - if contribution.ID == providerID { - return manifest, true - } - } - } - return protocol.Manifest{}, false -} - -func protocolManifestFromPluginView(plugin PluginView) protocol.Manifest { - return protocol.Manifest{ - APIVersion: plugin.Manifest.APIVersion, - Metadata: protocol.Metadata{ - ID: plugin.Manifest.ID, Version: plugin.Manifest.Version, Name: plugin.Manifest.Name, - Vendor: plugin.Manifest.Author, Description: plugin.Manifest.Description, - Documentation: plugin.Manifest.Documentation, - }, - Surfaces: plugin.Manifest.Surfaces, - Runtime: plugin.Manifest.Runtime, - Permissions: plugin.Manifest.Permissions, - Configuration: plugin.Manifest.Configuration, - Contributes: plugin.Manifest.Contributes, - } -} - -func paymentExpiryPolicy(manifest protocol.Manifest, providerID string) (protocol.ManifestPaymentExpiryPolicy, bool) { - for _, contribution := range manifest.Contributes.PaymentProviders { - if contribution.ID == providerID { - return contribution.ExpiryPolicy, true - } - } - if len(manifest.Contributes.PaymentProviders) == 1 { - return manifest.Contributes.PaymentProviders[0].ExpiryPolicy, true - } - return protocol.ManifestPaymentExpiryPolicy{}, false -} - -func (s *Service) decryptPaymentConfig(config *model.PaymentProviderConfig) (payment.Config, error) { - if config == nil { - return nil, errors.New("支付渠道配置不存在") - } - plain, err := s.decryptSettingSecret(config.ConfigCipher) - if err != nil { - return nil, err - } - values := make(payment.Config) - if err := json.Unmarshal([]byte(plain), &values); err != nil { - return nil, errors.New("支付渠道配置内容无效") - } - return values, nil -} - -func validatePaymentPublicBaseURL(value string) error { - parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(value), "/")) - if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Path != "" && parsed.Path != "/") { - return errors.New("服务器公网地址必须是有效的 HTTP(S) 根地址") - } - return nil -} - -func (s *Service) TopupProducts(actor *model.User) ([]model.TopupProduct, error) { - if actor == nil { - return nil, Unauthorized("请先登录") - } - if err := s.RequireFeature(FeatureCredits); err != nil { - return nil, err - } - products, err := s.repo.TopupProducts(false) - if err != nil { - return nil, err - } - return decorateTopupProducts(products, time.Now()), nil -} - -func (s *Service) AdminTopupProducts(actor *model.User) ([]model.TopupProduct, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - products, err := s.repo.TopupProducts(true) - if err != nil { - return nil, err - } - return decorateTopupProducts(products, time.Now()), nil -} - -func decorateTopupProducts(products []model.TopupProduct, now time.Time) []model.TopupProduct { - for index := range products { - product := &products[index] - if product.SaleStrategy == "" { - product.SaleStrategy = model.TopupSaleStrategyUnlimited - } - product.CanPurchase = product.Enabled - product.SaleStatus = "on_sale" - if !product.Enabled { - product.CanPurchase = false - product.SaleStatus = "disabled" - } else if product.SaleStrategy == model.TopupSaleStrategyInventory && product.StockRemaining <= 0 { - product.CanPurchase = false - product.SaleStatus = "sold_out" - } else if product.SaleStrategy == model.TopupSaleStrategyTimed { - if product.SaleStartAt != nil && now.Before(*product.SaleStartAt) { - product.CanPurchase = false - product.SaleStatus = "upcoming" - } else if product.SaleEndAt != nil && !now.Before(*product.SaleEndAt) { - product.CanPurchase = false - product.SaleStatus = "ended" - } - } - } - return products -} - -func (s *Service) CreateTopupProduct(actor *model.User, request TopupProductRequest) (*model.TopupProduct, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - product, err := topupProductFromRequest(newID(), actor.ID, request, 0, 0) - if err != nil { - return nil, err - } - if err := s.repo.CreateTopupProduct(product); err != nil { - return nil, err - } - if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits, "saleStrategy": product.SaleStrategy}); err != nil { - return nil, err - } - return product, nil -} - -func (s *Service) UpdateTopupProduct(actor *model.User, id string, request TopupProductRequest) (*model.TopupProduct, error) { - if err := s.RequireAdmin(actor); err != nil { - return nil, err - } - existing, err := s.repo.TopupProduct(id) - if err != nil { - return nil, err - } - consumed := existing.StockTotal - existing.StockRemaining - if existing.SaleStrategy != model.TopupSaleStrategyInventory { - consumed = 0 - } - product, err := topupProductFromRequest(strings.TrimSpace(id), actor.ID, request, existing.StockTotal, consumed) - if err != nil { - return nil, err - } - if err := s.repo.UpdateTopupProduct(product); err != nil { - return nil, err - } - if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled, "saleStrategy": product.SaleStrategy}); err != nil { - return nil, err - } - return s.repo.TopupProduct(product.ID) -} - -func topupProductFromRequest(id, actorID string, request TopupProductRequest, stockArgs ...int64) (*model.TopupProduct, error) { - var consumed int64 - if len(stockArgs) > 1 { - consumed = stockArgs[1] - } - name := strings.TrimSpace(request.Name) - if name == "" || len([]rune(name)) > 120 { - return nil, BadAuthRequest("充值商品名称不能为空且不能超过 120 个字符") - } - if request.AmountFen <= 0 || request.AmountFen > 100_000_000 { - return nil, BadAuthRequest("充值金额必须为 1 分至 100 万元") - } - if request.CreditsMicrocredits <= 0 || request.CreditsMicrocredits > maxTopupCreditsMicrocredits { - return nil, BadAuthRequest("充值积分必须为 0.000001 至 10 亿积分") - } - strategy := request.SaleStrategy - if strategy == "" { - strategy = model.TopupSaleStrategyUnlimited - } - if strategy != model.TopupSaleStrategyUnlimited && strategy != model.TopupSaleStrategyPeriodic && strategy != model.TopupSaleStrategyInventory && strategy != model.TopupSaleStrategyTimed { - return nil, BadAuthRequest("上架策略无效") - } - if strategy == model.TopupSaleStrategyPeriodic && (request.PeriodDays <= 0 || request.PeriodDays > 3650 || request.PeriodPurchaseLimit <= 0 || request.PeriodPurchaseLimit > 100000) { - return nil, BadAuthRequest("周期性商品需设置有效的周期天数和购买次数") - } - if strategy == model.TopupSaleStrategyInventory && request.StockTotal <= 0 { - return nil, BadAuthRequest("库存性商品库存必须大于 0") - } - if strategy == model.TopupSaleStrategyTimed && (request.SaleStartAt == nil || request.SaleEndAt == nil || !request.SaleEndAt.After(*request.SaleStartAt)) { - return nil, BadAuthRequest("限时商品必须设置有效的发售和结束时间") - } - stockRemaining := int64(0) - if strategy == model.TopupSaleStrategyInventory { - if consumed < 0 { - consumed = 0 - } - if request.StockTotal < consumed { - return nil, BadAuthRequest(fmt.Sprintf("库存不能低于已售数量(至少 %d)", consumed)) - } - stockRemaining = request.StockTotal - consumed - } - return &model.TopupProduct{ - ID: id, Name: name, Description: truncateRunes(strings.TrimSpace(request.Description), 500), - AmountFen: request.AmountFen, CreditsMicrocredits: request.CreditsMicrocredits, - Enabled: request.Enabled, SortOrder: request.SortOrder, SaleStrategy: strategy, - PeriodDays: request.PeriodDays, PeriodPurchaseLimit: request.PeriodPurchaseLimit, - StockTotal: request.StockTotal, StockRemaining: stockRemaining, - SaleStartAt: request.SaleStartAt, SaleEndAt: request.SaleEndAt, - CreatedBy: actorID, UpdatedBy: actorID, - }, nil -} - func (s *Service) CreatePaymentOrder(ctx context.Context, actor *model.User, request CreatePaymentOrderRequest) (*PaymentOrderView, error) { if actor == nil { return nil, Unauthorized("请先登录") @@ -1089,105 +635,3 @@ func (s *Service) AdminClosePaymentOrder(ctx context.Context, actor *model.User, view := paymentOrderView(*order) return &view, nil } - -func (s *Service) startPaymentWorker(ctx context.Context) { - s.runWorkerLoop(func(ctx context.Context) { - s.drainPaymentNotifications() - notificationTicker := time.NewTicker(15 * time.Second) - defer notificationTicker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-notificationTicker.C: - s.drainPaymentNotifications() - } - } - }) - s.runWorkerLoop(func(ctx context.Context) { - s.reconcileExpiredPaymentOrders(ctx) - orderTicker := time.NewTicker(15 * time.Second) - defer orderTicker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-orderTicker.C: - s.reconcileExpiredPaymentOrders(ctx) - s.queryPendingPaymentOrders(ctx) - } - } - }) - s.runWorkerLoop(func(ctx context.Context) { - s.maybeRunDailyPaymentReconciliation(ctx) - reconciliationTicker := time.NewTicker(30 * time.Minute) - defer reconciliationTicker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-reconciliationTicker.C: - s.maybeRunDailyPaymentReconciliation(ctx) - } - } - }) -} - -func (s *Service) drainPaymentNotifications() { - items, err := s.repo.PendingPaymentNotifications(32) - if err != nil { - log.Printf("payment notification query failed: %v", err) - return - } - for index := range items { - item := &items[index] - if err := s.processPaymentNotification(item); err != nil { - delay := time.Duration(math.Pow(2, math.Min(float64(item.Attempts), 8))) * 5 * time.Second - _ = s.repo.RetryPaymentNotification(item.ID, safePaymentError(err), time.Now().Add(delay)) - } - } -} - -func (s *Service) reconcileExpiredPaymentOrders(ctx context.Context) { - orders, err := s.repo.ClaimExpiredPaymentOrders(32) - if err != nil { - log.Printf("expired payment order claim failed: %v", err) - return - } - for index := range orders { - order := &orders[index] - operationContext, cancel := context.WithTimeout(ctx, 45*time.Second) - err := s.closePaymentOrder(operationContext, order) - cancel() - if err != nil { - _ = s.repo.RestoreClosingPaymentOrder(order.ID, safePaymentError(err)) - } - } -} - -func (s *Service) queryPendingPaymentOrders(ctx context.Context) { - orders, err := s.repo.PaymentOrdersNeedingQuery(time.Now().Add(-30*time.Second), 32) - if err != nil { - log.Printf("pending payment order query failed: %v", err) - return - } - for index := range orders { - operationContext, cancel := context.WithTimeout(ctx, 20*time.Second) - err := s.queryPaymentOrder(operationContext, &orders[index]) - cancel() - if err != nil { - log.Printf("payment order compensation query failed: order=%s error_type=%T", orders[index].ID, err) - } - } -} - -func safePaymentError(err error) string { - if err == nil { - return "" - } - var providerErr *payment.ProviderError - if errors.As(err, &providerErr) && providerErr.Code != "" { - return truncateRunes(providerErr.Code, 1000) - } - return truncateRunes(fmt.Sprintf("%T", err), 1000) -} diff --git a/backend/internal/app/payment_products.go b/backend/internal/app/payment_products.go new file mode 100644 index 000000000..6c08ec2f1 --- /dev/null +++ b/backend/internal/app/payment_products.go @@ -0,0 +1,157 @@ +// 充值商品:前台可购买的积分档位与管理端增改。 + +package app + +import ( + "fmt" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) TopupProducts(actor *model.User) ([]model.TopupProduct, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } + products, err := s.repo.TopupProducts(false) + if err != nil { + return nil, err + } + return decorateTopupProducts(products, time.Now()), nil +} + +func (s *Service) AdminTopupProducts(actor *model.User) ([]model.TopupProduct, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + products, err := s.repo.TopupProducts(true) + if err != nil { + return nil, err + } + return decorateTopupProducts(products, time.Now()), nil +} + +func decorateTopupProducts(products []model.TopupProduct, now time.Time) []model.TopupProduct { + for index := range products { + product := &products[index] + if product.SaleStrategy == "" { + product.SaleStrategy = model.TopupSaleStrategyUnlimited + } + product.CanPurchase = product.Enabled + product.SaleStatus = "on_sale" + if !product.Enabled { + product.CanPurchase = false + product.SaleStatus = "disabled" + } else if product.SaleStrategy == model.TopupSaleStrategyInventory && product.StockRemaining <= 0 { + product.CanPurchase = false + product.SaleStatus = "sold_out" + } else if product.SaleStrategy == model.TopupSaleStrategyTimed { + if product.SaleStartAt != nil && now.Before(*product.SaleStartAt) { + product.CanPurchase = false + product.SaleStatus = "upcoming" + } else if product.SaleEndAt != nil && !now.Before(*product.SaleEndAt) { + product.CanPurchase = false + product.SaleStatus = "ended" + } + } + } + return products +} + +func (s *Service) CreateTopupProduct(actor *model.User, request TopupProductRequest) (*model.TopupProduct, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + product, err := topupProductFromRequest(newID(), actor.ID, request, 0, 0) + if err != nil { + return nil, err + } + if err := s.repo.CreateTopupProduct(product); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "topup_product.create", "topup_product", product.ID, "创建积分充值商品", map[string]any{"amountFen": product.AmountFen, "creditsMicrocredits": product.CreditsMicrocredits, "saleStrategy": product.SaleStrategy}); err != nil { + return nil, err + } + return product, nil +} + +func (s *Service) UpdateTopupProduct(actor *model.User, id string, request TopupProductRequest) (*model.TopupProduct, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + existing, err := s.repo.TopupProduct(id) + if err != nil { + return nil, err + } + consumed := existing.StockTotal - existing.StockRemaining + if existing.SaleStrategy != model.TopupSaleStrategyInventory { + consumed = 0 + } + product, err := topupProductFromRequest(strings.TrimSpace(id), actor.ID, request, existing.StockTotal, consumed) + if err != nil { + return nil, err + } + if err := s.repo.UpdateTopupProduct(product); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "topup_product.update", "topup_product", product.ID, "更新积分充值商品", map[string]any{"enabled": product.Enabled, "saleStrategy": product.SaleStrategy}); err != nil { + return nil, err + } + return s.repo.TopupProduct(product.ID) +} + +func topupProductFromRequest(id, actorID string, request TopupProductRequest, stockArgs ...int64) (*model.TopupProduct, error) { + var consumed int64 + if len(stockArgs) > 1 { + consumed = stockArgs[1] + } + name := strings.TrimSpace(request.Name) + if name == "" || len([]rune(name)) > 120 { + return nil, BadAuthRequest("充值商品名称不能为空且不能超过 120 个字符") + } + if request.AmountFen <= 0 || request.AmountFen > 100_000_000 { + return nil, BadAuthRequest("充值金额必须为 1 分至 100 万元") + } + if request.CreditsMicrocredits <= 0 || request.CreditsMicrocredits > maxTopupCreditsMicrocredits { + return nil, BadAuthRequest("充值积分必须为 0.000001 至 10 亿积分") + } + strategy := request.SaleStrategy + if strategy == "" { + strategy = model.TopupSaleStrategyUnlimited + } + if strategy != model.TopupSaleStrategyUnlimited && strategy != model.TopupSaleStrategyPeriodic && strategy != model.TopupSaleStrategyInventory && strategy != model.TopupSaleStrategyTimed { + return nil, BadAuthRequest("上架策略无效") + } + if strategy == model.TopupSaleStrategyPeriodic && (request.PeriodDays <= 0 || request.PeriodDays > 3650 || request.PeriodPurchaseLimit <= 0 || request.PeriodPurchaseLimit > 100000) { + return nil, BadAuthRequest("周期性商品需设置有效的周期天数和购买次数") + } + if strategy == model.TopupSaleStrategyInventory && request.StockTotal <= 0 { + return nil, BadAuthRequest("库存性商品库存必须大于 0") + } + if strategy == model.TopupSaleStrategyTimed && (request.SaleStartAt == nil || request.SaleEndAt == nil || !request.SaleEndAt.After(*request.SaleStartAt)) { + return nil, BadAuthRequest("限时商品必须设置有效的发售和结束时间") + } + stockRemaining := int64(0) + if strategy == model.TopupSaleStrategyInventory { + if consumed < 0 { + consumed = 0 + } + if request.StockTotal < consumed { + return nil, BadAuthRequest(fmt.Sprintf("库存不能低于已售数量(至少 %d)", consumed)) + } + stockRemaining = request.StockTotal - consumed + } + return &model.TopupProduct{ + ID: id, Name: name, Description: truncateRunes(strings.TrimSpace(request.Description), 500), + AmountFen: request.AmountFen, CreditsMicrocredits: request.CreditsMicrocredits, + Enabled: request.Enabled, SortOrder: request.SortOrder, SaleStrategy: strategy, + PeriodDays: request.PeriodDays, PeriodPurchaseLimit: request.PeriodPurchaseLimit, + StockTotal: request.StockTotal, StockRemaining: stockRemaining, + SaleStartAt: request.SaleStartAt, SaleEndAt: request.SaleEndAt, + CreatedBy: actorID, UpdatedBy: actorID, + }, nil +} diff --git a/backend/internal/app/payment_providers.go b/backend/internal/app/payment_providers.go new file mode 100644 index 000000000..1c19f656c --- /dev/null +++ b/backend/internal/app/payment_providers.go @@ -0,0 +1,326 @@ +// 支付渠道配置:渠道列表、管理员配置保存(密钥加密存储)、清单与到期策略。 +// +// 渠道实现全部来自已校验的支付插件包;没有可用插件时渠道列表为空,下单失败关闭, +// 绝不回退到宿主内置实现。 + +package app + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/payment" + "infinite-canvas/backend/internal/protocol" +) + +func (s *Service) PaymentNotificationResponse(providerID string, success bool) (int, string, string) { + return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, success, http.StatusInternalServerError) +} + +func (s *Service) PaymentNotificationFailureResponse(providerID string, status int) (int, string, string) { + return PaymentNotificationResponseForWithRegistry(s.paymentRegistry, providerID, false, status) +} + +func PaymentNotificationResponseFor(providerID string, success bool, failureStatus int) (int, string, string) { + registry, _ := payment.NewRegistry() + return PaymentNotificationResponseForWithRegistry(registry, providerID, success, failureStatus) +} + +func PaymentNotificationResponseForWithRegistry(registry *payment.Registry, providerID string, success bool, failureStatus int) (int, string, string) { + if registry != nil { + if provider, ok := registry.Get(providerID); ok { + descriptor := provider.Descriptor() + response := descriptor.NotificationFailure + if success { + response = descriptor.NotificationSuccess + } + status := response.Status + if status == 0 { + status = failureStatus + } + contentType, body := response.ContentType, response.Body + if contentType == "" { + contentType = "text/plain; charset=utf-8" + } + return status, contentType, body + } + } + if success { + return http.StatusNoContent, "", "" + } + return failureStatus, "", "" +} + +func (s *Service) PaymentProviders(actor *model.User) ([]PaymentProviderView, error) { + if actor == nil { + return nil, Unauthorized("请先登录") + } + if err := s.RequireFeature(FeatureCredits); err != nil { + return nil, err + } + items := make([]PaymentProviderView, 0) + for _, descriptor := range s.paymentRegistry.Descriptors() { + view, _, err := s.paymentProviderView(descriptor) + if err != nil { + return nil, err + } + if view.Enabled && view.Configured { + items = append(items, view) + } + } + return items, nil +} + +func (s *Service) AdminPaymentProviders(actor *model.User) ([]AdminPaymentProviderView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + items := make([]AdminPaymentProviderView, 0) + for _, descriptor := range s.paymentRegistry.Descriptors() { + base, config, err := s.paymentProviderView(descriptor) + if err != nil { + return nil, err + } + manifest, _ := s.paymentManifestForProvider(descriptor.ID) + view := AdminPaymentProviderView{PaymentProviderView: base, Values: map[string]string{}, SecretConfigured: map[string]bool{}, ConfigFields: manifest.Configuration.Fields} + if config != nil { + values, err := s.decryptPaymentConfig(config) + if err != nil { + return nil, err + } + view.ConfigID = config.ID + view.ConfigEnabled = config.Enabled + view.Version = config.Version + view.UpdatedAt = &config.CreatedAt + for _, field := range manifest.Configuration.Fields { + if field.Secret { + view.SecretConfigured[field.Name] = strings.TrimSpace(values[field.Name]) != "" + continue + } + view.Values[field.Name] = values[field.Name] + } + } + items = append(items, view) + } + return items, nil +} + +func (s *Service) UpdatePaymentProviderConfig(actor *model.User, providerID string, request UpdatePaymentProviderConfigRequest) (*AdminPaymentProviderView, error) { + if err := s.RequireAdmin(actor); err != nil { + return nil, err + } + provider, ok := s.paymentRegistry.Get(providerID) + if !ok { + return nil, BadAuthRequest("未知支付渠道") + } + descriptor := provider.Descriptor() + manifest, ok := s.paymentManifestForProvider(descriptor.ID) + if !ok { + return nil, BadAuthRequest("支付插件清单不存在") + } + policy, ok := paymentExpiryPolicy(manifest, descriptor.ID) + if !ok { + return nil, BadAuthRequest("支付插件清单不存在") + } + if request.CloseAfterMinutes < policy.MinMinutes || request.CloseAfterMinutes > policy.MaxMinutes { + return nil, BadAuthRequest(fmt.Sprintf("未支付关闭时间必须为 %d-%d 分钟", policy.MinMinutes, policy.MaxMinutes)) + } + values := make(payment.Config) + previousIdentity := make(map[string]string) + current, err := s.repo.LatestPaymentProviderConfig(providerID) + if err == nil { + values, err = s.decryptPaymentConfig(current) + if err != nil { + return nil, err + } + for _, field := range descriptor.IdentityFields { + previousIdentity[field] = strings.TrimSpace(values[field]) + } + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + for _, field := range manifest.Configuration.Fields { + value, supplied := request.Values[field.Name] + value = strings.TrimSpace(value) + if field.Secret && (!supplied || value == "") { + continue + } + if !supplied && field.Default != nil && strings.TrimSpace(values[field.Name]) == "" { + value = strings.TrimSpace(fmt.Sprint(field.Default)) + } + values[field.Name] = value + } + changedIdentityField := "" + for field, previous := range previousIdentity { + if previous != "" && strings.TrimSpace(values[field]) != previous { + changedIdentityField = field + break + } + } + if changedIdentityField != "" { + orderCount, err := s.repo.PaymentOrderCountForProvider(providerID) + if err != nil { + return nil, err + } + if orderCount > 0 { + return nil, BadAuthRequest("该渠道已有历史订单,首期不支持切换商户身份;请保持 " + changedIdentityField + " 不变") + } + } + if base := values["publicBaseUrl"]; base != "" { + if err := validatePaymentPublicBaseURL(base); err != nil { + return nil, BadAuthRequest(err.Error()) + } + } + if request.Enabled { + if strings.TrimSpace(values["publicBaseUrl"]) == "" { + return nil, BadAuthRequest("启用支付渠道前必须填写服务器公网地址") + } + if err := provider.ValidateConfig(values); err != nil { + return nil, BadAuthRequest(err.Error()) + } + } + plain, err := json.Marshal(values) + if err != nil { + return nil, err + } + ciphertext, err := s.encryptSettingSecret(string(plain)) + if err != nil { + return nil, err + } + digest := sha256.Sum256(plain) + config := &model.PaymentProviderConfig{ + ID: newID(), ProviderID: descriptor.ID, PluginID: descriptor.PluginID, PluginVersion: descriptor.PluginVersion, + Enabled: request.Enabled, CloseAfterMinutes: request.CloseAfterMinutes, + ConfigCipher: ciphertext, ConfigDigest: hex.EncodeToString(digest[:]), CreatedBy: actor.ID, + } + if err := s.repo.CreatePaymentProviderConfig(config); err != nil { + return nil, err + } + if err := s.appendAdminAudit(actor, "payment_provider.config.update", "payment_provider", providerID, "更新支付渠道配置", map[string]any{ + "version": config.Version, "enabled": config.Enabled, "closeAfterMinutes": config.CloseAfterMinutes, + }); err != nil { + return nil, err + } + items, err := s.AdminPaymentProviders(actor) + if err != nil { + return nil, err + } + for index := range items { + if items[index].ID == providerID { + return &items[index], nil + } + } + return nil, errors.New("保存支付渠道配置后未找到渠道") +} + +func (s *Service) paymentProviderView(descriptor payment.Descriptor) (PaymentProviderView, *model.PaymentProviderConfig, error) { + view := PaymentProviderView{ID: descriptor.ID, PluginID: descriptor.PluginID, Name: descriptor.Name, Icon: descriptor.Icon, CheckoutMode: descriptor.CheckoutMode} + if manifest, ok := s.paymentManifestForProvider(descriptor.ID); ok { + if policy, found := paymentExpiryPolicy(manifest, descriptor.ID); found { + view.CloseAfterMinutes = policy.DefaultMinutes + } + } + state, err := s.pluginStateForUser(nil, descriptor.PluginID, s.Plugins()) + if err == nil { + view.PluginEnabled = state.PlatformAvailable + view.Enabled = state.PlatformAvailable + } + config, configErr := s.repo.LatestPaymentProviderConfig(descriptor.ID) + if errors.Is(configErr, gorm.ErrRecordNotFound) { + return view, nil, nil + } + if configErr != nil { + return view, nil, configErr + } + view.Configured = strings.TrimSpace(config.ConfigCipher) != "" + view.Enabled = view.Enabled && config.Enabled + view.CloseAfterMinutes = config.CloseAfterMinutes + return view, config, nil +} + +func (s *Service) paymentManifestForProvider(providerID string) (protocol.Manifest, bool) { + providerID = strings.TrimSpace(providerID) + if providerID == "" { + return protocol.Manifest{}, false + } + if s != nil { + for _, plugin := range s.Plugins() { + for _, contribution := range plugin.Manifest.Contributes.PaymentProviders { + if contribution.ID == providerID { + return protocolManifestFromPluginView(plugin), true + } + } + } + } + return bundledPaymentManifestForProvider(providerID) +} + +func bundledPaymentManifestForProvider(providerID string) (protocol.Manifest, bool) { + for _, manifest := range bundledPaymentPluginManifests() { + for _, contribution := range manifest.Contributes.PaymentProviders { + if contribution.ID == providerID { + return manifest, true + } + } + } + return protocol.Manifest{}, false +} + +func protocolManifestFromPluginView(plugin PluginView) protocol.Manifest { + return protocol.Manifest{ + APIVersion: plugin.Manifest.APIVersion, + Metadata: protocol.Metadata{ + ID: plugin.Manifest.ID, Version: plugin.Manifest.Version, Name: plugin.Manifest.Name, + Vendor: plugin.Manifest.Author, Description: plugin.Manifest.Description, + Documentation: plugin.Manifest.Documentation, + }, + Surfaces: plugin.Manifest.Surfaces, + Runtime: plugin.Manifest.Runtime, + Permissions: plugin.Manifest.Permissions, + Configuration: plugin.Manifest.Configuration, + Contributes: plugin.Manifest.Contributes, + } +} + +func paymentExpiryPolicy(manifest protocol.Manifest, providerID string) (protocol.ManifestPaymentExpiryPolicy, bool) { + for _, contribution := range manifest.Contributes.PaymentProviders { + if contribution.ID == providerID { + return contribution.ExpiryPolicy, true + } + } + if len(manifest.Contributes.PaymentProviders) == 1 { + return manifest.Contributes.PaymentProviders[0].ExpiryPolicy, true + } + return protocol.ManifestPaymentExpiryPolicy{}, false +} + +func (s *Service) decryptPaymentConfig(config *model.PaymentProviderConfig) (payment.Config, error) { + if config == nil { + return nil, errors.New("支付渠道配置不存在") + } + plain, err := s.decryptSettingSecret(config.ConfigCipher) + if err != nil { + return nil, err + } + values := make(payment.Config) + if err := json.Unmarshal([]byte(plain), &values); err != nil { + return nil, errors.New("支付渠道配置内容无效") + } + return values, nil +} + +func validatePaymentPublicBaseURL(value string) error { + parsed, err := url.Parse(strings.TrimRight(strings.TrimSpace(value), "/")) + if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" || parsed.RawQuery != "" || (parsed.Path != "" && parsed.Path != "/") { + return errors.New("服务器公网地址必须是有效的 HTTP(S) 根地址") + } + return nil +} diff --git a/backend/internal/app/payment_runtime_cache_test.go b/backend/internal/app/payment_runtime_cache_test.go index ea9464b5e..9170a1b1d 100644 --- a/backend/internal/app/payment_runtime_cache_test.go +++ b/backend/internal/app/payment_runtime_cache_test.go @@ -5,6 +5,7 @@ import ( "bytes" "os" "path/filepath" + "runtime" "testing" "infinite-canvas/backend/internal/protocol" @@ -165,6 +166,35 @@ func TestPaymentRuntimeReadyAcceptsTaggedBackend(t *testing.T) { } } +// 启动时从插件包落盘支付运行目录只解压本机平台的可执行文件,其余平台产物不写盘; +// 已就绪的同摘要目录直接复用,不重新解压。 +func TestPaymentRuntimeFromPackageExtractsOnlyHostArtifacts(t *testing.T) { + host := "backend/provider-" + runtime.GOOS + "-" + runtime.GOARCH + other := "backend/provider-plan9-386" + packageData := paymentRuntimePackageFiles(t, map[string][]byte{host: []byte("host-provider"), other: []byte("other-provider")}) + packageDir := t.TempDir() + digest := pluginHash(packageData) + root, err := materializePaymentBackendFromPackage(packageDir, digest, "backend/provider", packageData) + if err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(host))) + if err != nil || string(actual) != "host-provider" { + t.Fatalf("host provider = %q err=%v", actual, err) + } + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(other))); !os.IsNotExist(err) { + t.Fatalf("non-host provider must not be extracted: %v", err) + } + if !paymentRuntimeReady(root, digest, "backend/provider") { + t.Fatal("runtime must be marked ready") + } + // 就绪后即使传入损坏的包字节也直接复用:摘要与就绪标记一一对应,调用方已对原始字节校验摘要。 + again, err := materializePaymentBackendFromPackage(packageDir, digest, "backend/provider", []byte("not a zip")) + if err != nil || again != root { + t.Fatalf("ready runtime was not reused: root=%q err=%v", again, err) + } +} + func paymentRuntimePackage(t *testing.T, provider []byte) []byte { t.Helper() return paymentRuntimePackageFiles(t, map[string][]byte{"backend/provider": provider}) diff --git a/backend/internal/app/payment_worker.go b/backend/internal/app/payment_worker.go new file mode 100644 index 000000000..9c08abf93 --- /dev/null +++ b/backend/internal/app/payment_worker.go @@ -0,0 +1,118 @@ +// 支付后台 worker:消费异步通知、关闭过期订单、补查待支付订单。 +// +// 通知与补查走同一条 applyPaymentResult 幂等入账路径,重复通知不会重复加积分。 + +package app + +import ( + "context" + "errors" + "fmt" + "log" + "math" + "time" + + "infinite-canvas/backend/internal/payment" +) + +func (s *Service) startPaymentWorker(ctx context.Context) { + s.runWorkerLoop(func(ctx context.Context) { + s.drainPaymentNotifications() + notificationTicker := time.NewTicker(15 * time.Second) + defer notificationTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-notificationTicker.C: + s.drainPaymentNotifications() + } + } + }) + s.runWorkerLoop(func(ctx context.Context) { + s.reconcileExpiredPaymentOrders(ctx) + orderTicker := time.NewTicker(15 * time.Second) + defer orderTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-orderTicker.C: + s.reconcileExpiredPaymentOrders(ctx) + s.queryPendingPaymentOrders(ctx) + } + } + }) + s.runWorkerLoop(func(ctx context.Context) { + s.maybeRunDailyPaymentReconciliation(ctx) + reconciliationTicker := time.NewTicker(30 * time.Minute) + defer reconciliationTicker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-reconciliationTicker.C: + s.maybeRunDailyPaymentReconciliation(ctx) + } + } + }) +} + +func (s *Service) drainPaymentNotifications() { + items, err := s.repo.PendingPaymentNotifications(32) + if err != nil { + log.Printf("payment notification query failed: %v", err) + return + } + for index := range items { + item := &items[index] + if err := s.processPaymentNotification(item); err != nil { + delay := time.Duration(math.Pow(2, math.Min(float64(item.Attempts), 8))) * 5 * time.Second + _ = s.repo.RetryPaymentNotification(item.ID, safePaymentError(err), time.Now().Add(delay)) + } + } +} + +func (s *Service) reconcileExpiredPaymentOrders(ctx context.Context) { + orders, err := s.repo.ClaimExpiredPaymentOrders(32) + if err != nil { + log.Printf("expired payment order claim failed: %v", err) + return + } + for index := range orders { + order := &orders[index] + operationContext, cancel := context.WithTimeout(ctx, 45*time.Second) + err := s.closePaymentOrder(operationContext, order) + cancel() + if err != nil { + _ = s.repo.RestoreClosingPaymentOrder(order.ID, safePaymentError(err)) + } + } +} + +func (s *Service) queryPendingPaymentOrders(ctx context.Context) { + orders, err := s.repo.PaymentOrdersNeedingQuery(time.Now().Add(-30*time.Second), 32) + if err != nil { + log.Printf("pending payment order query failed: %v", err) + return + } + for index := range orders { + operationContext, cancel := context.WithTimeout(ctx, 20*time.Second) + err := s.queryPaymentOrder(operationContext, &orders[index]) + cancel() + if err != nil { + log.Printf("payment order compensation query failed: order=%s error_type=%T", orders[index].ID, err) + } + } +} + +func safePaymentError(err error) string { + if err == nil { + return "" + } + var providerErr *payment.ProviderError + if errors.As(err, &providerErr) && providerErr.Code != "" { + return truncateRunes(providerErr.Code, 1000) + } + return truncateRunes(fmt.Sprintf("%T", err), 1000) +} diff --git a/backend/internal/app/protocol_plugins.go b/backend/internal/app/protocol_plugins.go index 1d9e92eb5..b83cfa8ea 100644 --- a/backend/internal/app/protocol_plugins.go +++ b/backend/internal/app/protocol_plugins.go @@ -9,6 +9,7 @@ import ( "io/fs" "os" "path/filepath" + "runtime" "sort" "strings" "sync" @@ -140,14 +141,11 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error { if entry.IsDir() || !strings.HasSuffix(strings.ToLower(entry.Name()), ".yingce-plugin") { continue } - packageData, err := os.ReadFile(filepath.Join(officialDir, entry.Name())) - if err != nil { - return fmt.Errorf("读取官方插件包 %s:%w", entry.Name(), err) - } - pkg, err := protocol.ParsePluginPackage(packageData) + official, err := inspectOfficialPluginPackage(filepath.Join(officialDir, entry.Name())) if err != nil { return fmt.Errorf("校验官方插件包 %s:%w", entry.Name(), err) } + packageData, pkg := official.data, official.info if strings.HasPrefix(strings.TrimSpace(pkg.Manifest.Runtime.Backend), "host:") { return fmt.Errorf("官方插件 %q 不能依赖 host 执行器", pkg.Manifest.Metadata.ID) } @@ -173,9 +171,9 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error { if err != nil { return fmt.Errorf("编码官方插件 %q:%w", id, err) } - hash := pluginHash(packageData) + hash := official.hash packageName := hash + ".yingce-plugin" - if err := writePluginFile(filepath.Join(c.packageDir, packageName), packageData); err != nil { + if err := cachePluginPackage(filepath.Join(c.packageDir, packageName), packageData, official.path); err != nil { return fmt.Errorf("缓存官方插件 %q:%w", id, err) } now := time.Now().UTC() @@ -245,6 +243,59 @@ func (c *pluginRuntime) bootstrapBuiltInPlugins() error { return c.writeRegistry(result) } +// officialPluginPackage 是一次读取官方插件包得到的全部结果:原始字节、摘要与清单。 +type officialPluginPackage struct { + path string + data []byte + hash string + info protocol.PluginPackageInfo +} + +type officialPluginPackageKey struct { + path string + size int64 + modTime int64 +} + +// officialPluginPackageCache 在进程内缓存官方插件包的读取结果。 +// +// 官方包随镜像发布、运行期间不会变化;以路径 + 大小 + 修改时间作键,文件被替换时 +// 自然失效。生产环境每个进程只构造一次 Service,缓存几乎无收益;但测试会构造上千个 +// Service,每次重新读取、哈希、解析 85MB 的官方包会让后端测试慢数分钟。 +var officialPluginPackageCache sync.Map // officialPluginPackageKey -> officialPluginPackage + +func inspectOfficialPluginPackage(path string) (officialPluginPackage, error) { + info, err := os.Stat(path) + if err != nil { + return officialPluginPackage{}, err + } + key := officialPluginPackageKey{path: path, size: info.Size(), modTime: info.ModTime().UnixNano()} + if cached, ok := officialPluginPackageCache.Load(key); ok { + result := cached.(officialPluginPackage) + // Manifest 含指针与切片字段;每个调用方拿到独立解码的副本,缓存条目永远不会被共享修改。 + // 清单只有几 KB,重新解码的成本可以忽略,昂贵的是读包、哈希与 zip 结构校验。 + var manifest protocol.Manifest + if err := json.Unmarshal(result.info.ManifestRaw, &manifest); err != nil { + return officialPluginPackage{}, err + } + result.info.Manifest = manifest + return result, nil + } + data, err := os.ReadFile(path) + if err != nil { + return officialPluginPackage{}, err + } + // 启动只需要清单:支付插件包内含多平台可执行文件(解压约 44MB/个), + // 真正落盘执行时 reload 会按摘要校验并只解压本机平台需要的文件。 + pkg, err := protocol.InspectPluginPackage(data) + if err != nil { + return officialPluginPackage{}, err + } + result := officialPluginPackage{path: path, data: data, hash: pluginHash(data), info: pkg} + officialPluginPackageCache.Store(key, result) + return result, nil +} + func isSystemPaymentPluginID(id string) bool { _, ok := systemPaymentPolicies[strings.TrimSpace(id)] return ok @@ -435,14 +486,7 @@ func (c *pluginRuntime) reload() error { record.PackageSHA256 = packageSHA256 record.SHA256 = packageSHA256 plugins[id] = record - pkg, parseErr := protocol.ParsePluginPackage(packageData) - if parseErr != nil { - record.Status = "invalid" - record.Error = parseErr.Error() - plugins[id] = record - continue - } - runtimeDir, materializeErr := materializePaymentBackend(c.packageDir, packageSHA256, pkg) + runtimeDir, materializeErr := materializePaymentBackendFromPackage(c.packageDir, packageSHA256, manifest.Runtime.BackendEntry, packageData) if materializeErr != nil { record.Status = "invalid" record.Error = materializeErr.Error() @@ -478,6 +522,96 @@ func (c *pluginRuntime) reload() error { return nil } +// materializePaymentBackendFromPackage 先按摘要检查运行目录是否已就绪,只有需要落盘时 +// 才完整解析插件包。摘要已由调用方对原始字节校验,就绪标记与摘要一一对应, +// 因此跳过解析不会放过被篡改的包。 +func materializePaymentBackendFromPackage(packageDir, hash, backendEntry string, packageData []byte) (string, error) { + digest := strings.ToLower(strings.TrimSpace(hash)) + root := filepath.Join(packageDir, "runtime", digest) + if len(digest) == sha256.Size*2 && paymentRuntimeReady(root, digest, strings.TrimSpace(backendEntry)) { + return root, nil + } + if len(digest) == sha256.Size*2 && linkReadyPaymentRuntime(packageDir, digest, strings.TrimSpace(backendEntry)) { + return root, nil + } + defer func() { rememberPaymentRuntime(digest, root, strings.TrimSpace(backendEntry)) }() + // 只解压本机平台能执行的文件:官方包内含 7 个平台的可执行文件, + // 其余平台的产物永远不会被 ResolveRPCBackendPath 选中,解压只是浪费 IO。 + entry := strings.TrimSpace(backendEntry) + hostCandidates := make(map[string]struct{}) + for _, candidate := range protocol.PaymentRPCBackendCandidates(entry, runtime.GOOS, runtime.GOARCH) { + hostCandidates[candidate] = struct{}{} + } + pkg, err := protocol.ParsePluginPackageFiles(packageData, func(name string) bool { + if !strings.HasPrefix(name, "backend/") { + return false + } + if _, host := hostCandidates[name]; host { + return true + } + return !protocol.IsPaymentRPCBackendArtifact(entry, name) + }) + if err != nil { + return "", err + } + return materializePaymentBackend(packageDir, hash, pkg) +} + +// readyPaymentRuntimes 记录本进程内已就绪的支付插件运行目录(摘要 -> 目录)。 +// 同一摘要的运行目录内容完全相同,新的数据目录可以直接硬链接复用, +// 避免每次重新解压约 6MB 的可执行文件。生产环境只有一个数据目录,这里不会命中。 +var readyPaymentRuntimes sync.Map // digest -> root + +func rememberPaymentRuntime(digest, root, backendEntry string) { + if paymentRuntimeReady(root, digest, backendEntry) { + readyPaymentRuntimes.Store(digest, root) + } +} + +// linkReadyPaymentRuntime 把本进程已就绪的同摘要运行目录硬链接到 packageDir。 +// 源目录必须仍然通过就绪校验;任何一步失败都返回 false,由调用方走完整解压。 +func linkReadyPaymentRuntime(packageDir, digest, backendEntry string) bool { + cached, ok := readyPaymentRuntimes.Load(digest) + if !ok { + return false + } + source := cached.(string) + root := filepath.Join(packageDir, "runtime", digest) + if source == root || !paymentRuntimeReady(source, digest, backendEntry) { + return false + } + runtimeRoot := filepath.Join(packageDir, "runtime") + if err := os.MkdirAll(runtimeRoot, 0o700); err != nil { + return false + } + temporaryRoot, err := os.MkdirTemp(runtimeRoot, ".payment-runtime-") + if err != nil { + return false + } + defer os.RemoveAll(temporaryRoot) + linkErr := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + target := filepath.Join(temporaryRoot, rel) + if entry.IsDir() { + return os.MkdirAll(target, 0o700) + } + if !entry.Type().IsRegular() { + return fmt.Errorf("unexpected runtime entry %q", rel) + } + return os.Link(path, target) + }) + if linkErr != nil || os.Rename(temporaryRoot, root) != nil { + return false + } + return paymentRuntimeReady(root, digest, backendEntry) +} + func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPackage) (string, error) { digest := strings.ToLower(strings.TrimSpace(hash)) if len(digest) != sha256.Size*2 { @@ -503,7 +637,8 @@ func materializePaymentBackend(packageDir, hash string, pkg protocol.PluginPacka defer os.RemoveAll(temporaryRoot) for name, content := range pkg.Files { - if !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") { + // content 为 nil 表示该条目按需未解压(见 ParsePluginPackageFiles),不能写成空文件。 + if content == nil || !strings.HasPrefix(name, "backend/") || strings.HasSuffix(name, "/") { continue } target := filepath.Join(temporaryRoot, filepath.FromSlash(name)) @@ -781,6 +916,22 @@ func (c *pluginRuntime) writeRegistry(records []pluginRegistryRecord) error { return writePluginFile(c.registryPath, data) } +// cachePluginPackage 写入以内容摘要命名的插件包缓存。文件名即摘要,且 writePluginFile +// 通过临时文件 + rename 原子发布,所以同名文件存在且大小一致就是同一份内容,无需重写。 +// +// source 非空时先尝试硬链接到官方包原文件(同一文件系统内零拷贝);跨文件系统 +// (生产环境镜像目录与 /data 卷)会失败并回退为普通写入。即使链接到的内容与摘要 +// 不一致,reload 也会按摘要校验并把插件标为 invalid,不会放过错误的包。 +func cachePluginPackage(path string, data []byte, source string) error { + if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size() == int64(len(data)) { + return nil + } + if source != "" && os.Link(source, path) == nil { + return nil + } + return writePluginFile(path, data) +} + func writePluginFile(path string, data []byte) error { temporary, err := os.CreateTemp(filepath.Dir(path), ".plugin-*.tmp") if err != nil { diff --git a/backend/internal/app/provider.go b/backend/internal/app/provider.go index e8a7f9d6c..1e2f48e65 100644 --- a/backend/internal/app/provider.go +++ b/backend/internal/app/provider.go @@ -3,23 +3,17 @@ package app // 画布生成任务调度、输入水合与跨能力共享类型/辅助函数。 import ( - "bytes" "context" "encoding/base64" "encoding/json" "errors" "fmt" - "infinite-canvas/backend/internal/kernel" - "io" - "net/http" "regexp" - "strconv" "strings" "time" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" - - "gorm.io/gorm" ) var sseFrameBoundaryPattern = regexp.MustCompile(`\r?\n\r?\n`) @@ -104,7 +98,9 @@ type providerConfig struct { } const providerHTTPTimeout = 5 * time.Minute + const videoPollTimeout = time.Hour + const maxProviderResponseBytes int64 = 64 << 20 type providerMedia struct { @@ -128,213 +124,6 @@ type imageResponse struct { Msg string `json:"msg"` } -type providerError struct { - Message string `json:"message"` -} - -// providerPayloadError 在进程内保留上游原始原因,供协议兼容分支做机器判断; -// 对调用方只暴露经过过滤的错误原因。Provider 正文可能包含密钥或内部诊断, -// 禁止原样进入用户错误和日志。 -type providerPayloadError struct { - raw string - message string -} - -func (e providerPayloadError) Error() string { return e.message } - -type providerHTTPError struct { - StatusCode int - Status string - Body string - RetryAfter time.Duration -} - -type providerResponseDecodeError struct { - Err error -} - -func (e providerResponseDecodeError) Error() string { return e.Err.Error() } -func (e providerResponseDecodeError) Unwrap() error { return e.Err } - -type providerCircuitOpenError struct{} - -func (providerCircuitOpenError) Error() string { - return "当前渠道连续失败,已暂时熔断,请稍后重试" -} - -type providerStatePendingError struct { - TaskID string - Cause error -} - -func (e providerStatePendingError) Error() string { - return fmt.Sprintf("上游任务状态尚未同步,将继续查询原任务(任务 %s)", e.TaskID) -} - -func (e providerStatePendingError) Unwrap() error { return e.Cause } - -type providerAnalyticsKey struct{} - -type providerAnalyticsContext struct { - Service *Service - Billing taskBillingLifecycle - UserID string - TaskID string - TraceID string - RequestID string - BillingOrderID string - BillingMode string - Capability string - Operation string - ChannelID string - Model string - VideoSeconds int - RequestKind string - ProviderRequestID string - ConcurrencyLimit int -} - -func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context { - metadata := providerAnalyticsContext{Service: service, UserID: task.UserID, TaskID: task.ID, TraceID: task.TraceID, RequestID: task.RequestID, BillingOrderID: task.BillingOrderID, Capability: capabilityFromTaskType(task.Type), Operation: task.Operation, Model: task.Model, ProviderRequestID: task.ProviderRequestID} - if service != nil { - metadata.Billing = service.taskBilling() - } - // 账单模式随请求上下文传递,流式协议据此只为 Token 计费开启 usage 终态块。 - if service != nil && task.BillingOrderID != "" { - if order, err := service.repo.BillingOrder(task.BillingOrderID); err == nil { - metadata.BillingMode = order.BillingMode - } - } - var input struct { - Mode string `json:"mode"` - Config providerConfig `json:"config"` - } - if json.Unmarshal([]byte(task.InputJSON), &input) == nil { - metadata.ChannelID = firstNonEmpty(input.Config.ChannelID, systemChannelIDFromBaseURL(input.Config.BaseURL)) - metadata.Model = firstNonEmpty(input.Config.ChannelModelKey, input.Config.Model, metadata.Model) - metadata.VideoSeconds, _ = strconv.Atoi(input.Config.VideoSeconds) - if normalized := normalizeCapability(input.Mode); normalized != "" { - metadata.Capability = normalized - } - } - return context.WithValue(ctx, providerAnalyticsKey{}, metadata) -} - -func resumedProviderRequestID(ctx context.Context) string { - metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) - return strings.TrimSpace(metadata.ProviderRequestID) -} - -func withProviderRequestKind(ctx context.Context, requestKind string) context.Context { - metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) - if !ok { - return ctx - } - metadata.RequestKind = requestKind - return context.WithValue(ctx, providerAnalyticsKey{}, metadata) -} - -func (e providerHTTPError) Error() string { - if e.StatusCode == http.StatusBadRequest || e.StatusCode == http.StatusUnprocessableEntity { - return providerErrorWithDetail(e.summary(), e.Body) - } - return appendProviderErrorDetail(e.summary(), e.Body) -} - -func (e providerHTTPError) summary() string { - switch e.StatusCode { - case 524: - return "上游网关超时(524):模型请求可能仍在服务端执行并产生费用,请勿立即重试,请先到供应商后台核对任务或账单" - case http.StatusBadRequest, http.StatusUnprocessableEntity: - return "模型服务拒绝了请求,请检查模型和参数" - case http.StatusUnauthorized, http.StatusForbidden: - return "模型服务鉴权失败,请检查 API Key 和模型权限" - case http.StatusNotFound: - return "模型或模型接口不存在,请检查渠道配置" - case http.StatusRequestTimeout, http.StatusGatewayTimeout: - return "模型服务响应超时,请稍后重试" - case http.StatusTooManyRequests: - return "模型服务请求过于频繁或额度不足,请稍后重试" - } - if e.StatusCode >= http.StatusInternalServerError { - return fmt.Sprintf("模型服务暂时不可用(HTTP %d)", e.StatusCode) - } - return fmt.Sprintf("模型服务请求失败(HTTP %d)", e.StatusCode) -} - -func providerUserFacingErrorMessage(err error) string { - if err == nil { - return "模型服务请求失败" - } - if errors.Is(err, context.Canceled) { - return "模型请求已取消" - } - if errors.Is(err, context.DeadlineExceeded) { - return "模型服务响应超时,请稍后重试" - } - var appErr *AppError - if errors.As(err, &appErr) && strings.TrimSpace(appErr.Message) != "" { - return appErr.Message - } - var httpErr providerHTTPError - if errors.As(err, &httpErr) { - return httpErr.Error() - } - return "连接模型服务失败,请检查渠道地址和网络" -} - -// providerPayloadErrorCategory 把上游失败正文归类为固定的用户可见原因。 -// 第二个返回值为 false 表示正文无法归类,调用方应退回到更通用的提示, -// 不要因为归类失败就把正文本身当作错误信息。 -// 正文可能包含密钥或内部诊断信息,只能参与归类,不得回传用户或写入日志。 -func providerPayloadErrorCategory(raw string) (string, bool) { - normalized := strings.ToLower(strings.TrimSpace(raw)) - if normalized == "" { - return "", false - } - switch { - // 真人肖像类目只匹配供应商错误码里的稳定标识,不扫描自然语言。 - // 正文常常回显用户提示词,"likeness"、"肖像"这类词单独出现并不能证明 - // 上游是因为真人形象拒绝,按词判断会把普通参数错误误报成肖像问题。 - // 该类目排在安全审核之前:错误码已经足够具体,比通用审核提示更可行动。 - case strings.Contains(normalized, "privacyinformation"), strings.Contains(normalized, "sensitivecontentdetected"): - return "输入素材疑似包含真人形象,该模型拒绝生成,请更换为非真人素材或改用其他模型", true - case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"): - return "请求内容未通过模型服务安全审核,请调整后重试", true - // 工具调用与工具结果不配对:上游要求 assistant 消息声明的每一个 tool_call_id 都在 - // 紧随其后的 tool 消息里被回应。这是**我们组装请求**的问题——用户改提示词或查额度 - // 都没用——所以文案指向反馈而不是"调整输入"。 - // - // 必须排在额度类目之前:DeepSeek 的原文含 "insufficient", - // "An assistant message with 'tool_calls' must be followed by tool messages - // responding to each 'tool_call_id'. (insufficient tool messages following - // tool_calls message)" 落到额度类目就会把协议错误报成"渠道余额不足", - // 掩盖真正的原因(历史里的工具结果不连续)。 - case strings.Contains(normalized, "must be followed by tool messages"), - strings.Contains(normalized, "insufficient tool messages following"): - return "会话里的工具调用与结果不匹配,本轮已停止;这不是额度或提示词问题,如反复出现请反馈", true - case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"): - return "模型服务额度不足,请检查渠道余额或配额", true - case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")): - return "模型不存在或当前渠道未获得模型权限", true - // 推理/思考模式模型通常禁止强制指定工具调用:DeepSeek 思考模式返回 - // "Thinking mode does not support this tool_choice",其他 OpenAI 兼容 - // 供应商措辞类似。归为固定可行动原因;显式思考模式会在出站前省略 - // tool_choice,未声明但由上游隐式开启思考时再按兼容序列重试。排在 - // 通用参数类目之前,避免稳定标识落回笼统的"请检查模型和参数"。 - case (strings.Contains(normalized, "thinking") || strings.Contains(normalized, "reasoning")) && strings.Contains(normalized, "tool_choice"), - strings.Contains(normalized, "tool_choice") && (strings.Contains(normalized, "not support") || strings.Contains(normalized, "unsupported")): - return "当前模型为思考/推理模式,不支持强制工具调用(tool_choice=required),请改用自动工具选择或更换非思考模式模型", true - case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"): - return "模型服务拒绝了请求,请检查模型和参数", true - } - return "", false -} - -func providerPayloadErrorMessage(raw string) string { - return providerErrorWithDetail("模型服务返回失败,请检查请求内容或渠道配置", raw) -} - func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string, taskProjectID string, taskType string, fallbackPrompt string, rawInput string) (map[string]interface{}, error) { ctx = withProtocolRegistry(ctx, s.protocolRegistry()) var input canvasGenerationInput @@ -528,350 +317,6 @@ func providerPrefersMediaURLs(interfaceType string, input canvasGenerationInput) return false } -type styleExecutionPlanDocument struct { - SchemaVersion int `json:"schemaVersion"` - ProfilePresetID string `json:"profilePresetId"` - ProfileRevision int `json:"profileRevision"` - Mode string `json:"mode"` - Model string `json:"model"` - InterfaceType string `json:"interfaceType"` - Status string `json:"status"` - Prompt string `json:"prompt"` -} - -func (s *Service) applyGenerationStyleProfile(userID string, taskProjectID string, input *canvasGenerationInput) error { - styleProfileJSON := metadataString(input.Metadata, "styleProfileJson") - if strings.TrimSpace(styleProfileJSON) == "" { - return nil - } - if _, err := validateStyleProfileJSON(styleProfileJSON); err != nil { - return fmt.Errorf("项目画风执行配置无效:%w", err) - } - var profile styleProfileDocument - if err := json.Unmarshal([]byte(styleProfileJSON), &profile); err != nil { - return fmt.Errorf("项目画风执行配置解析失败:%w", err) - } - storedProfileJSON, storedPresetID, belongsToProject, err := s.taskProjectStyleProfile(userID, taskProjectID) - if err != nil { - return fmt.Errorf("读取项目画风失败:%w", err) - } - if belongsToProject { - if strings.TrimSpace(storedProfileJSON) == "" { - // 旧项目只有 preset ID,允许画布把该预设编译为结构化快照;仍需锁定同一预设,不能借降级路径换画风。 - if strings.TrimSpace(storedPresetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(storedPresetID) { - return errors.New("项目画风已发生变化,请返回项目列表后重新打开当前项目再生成") - } - } else { - matches, compareErr := equivalentStyleProfileJSON(styleProfileJSON, storedProfileJSON) - if compareErr != nil { - return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试") - } - if !matches { - // 项目画风可能在另一个页面更新;保存路径以服务端快照为准,生成时自动采用最新版本。 - validatedStoredProfileJSON, validateErr := validateStyleProfileJSON(storedProfileJSON) - if validateErr != nil || json.Unmarshal([]byte(validatedStoredProfileJSON), &profile) != nil { - return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试") - } - } - } - } - // 执行计划是前端为即时预览生成的派生数据。平台模型入队后可能改选真实供应线路, - // 因此后端必须以最终模型重新编译,不能要求用户手动“刷新配置”来同步内部路由。 - plan, _ := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"]) - stylePrompt, expectedStatus, warnings := resolveGenerationStyleExecution(profile, input.Config.Model, firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat)) - input.Prompt = reconcileGenerationStylePrompt(input.Prompt, plan.Prompt, stylePrompt) - if expectedStatus == "blocked" { - return fmt.Errorf("当前图片模型无法完整执行项目画风:%s。请切换图片模型,或在项目设置中停用对应画风资产", strings.Join(warnings, ";")) - } - return nil -} - -func reconcileGenerationStylePrompt(prompt string, previousStylePrompt string, currentStylePrompt string) string { - content := strings.TrimSpace(prompt) - previous := strings.TrimSpace(previousStylePrompt) - if previous != "" { - previousBlock := "【项目画风执行规范】\n" + previous - if strings.HasSuffix(content, previousBlock) { - content = strings.TrimSpace(strings.TrimSuffix(content, previousBlock)) - } - } - current := strings.TrimSpace(currentStylePrompt) - if current == "" || strings.HasSuffix(content, "【项目画风执行规范】\n"+current) { - return content - } - return strings.TrimSpace(content + "\n\n【项目画风执行规范】\n" + current) -} - -func (s *Service) taskProjectStyleProfile(userID string, canvasOrProjectID string) (string, string, bool, error) { - id := strings.TrimSpace(canvasOrProjectID) - if id == "" { - return "", "", false, nil - } - if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil { - if strings.TrimSpace(canvas.ProjectID) == "" { - return "", "", false, nil - } - project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID) - if projectErr != nil { - return "", "", true, projectErr - } - return project.StyleProfileJSON, project.StylePresetID, true, nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return "", "", false, err - } - project, err := s.repo.ProjectForUser(userID, id) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return "", "", false, nil - } - return "", "", false, err - } - return project.StyleProfileJSON, project.StylePresetID, true, nil -} - -func equivalentStyleProfileJSON(left string, right string) (bool, error) { - var leftValue interface{} - if err := json.Unmarshal([]byte(left), &leftValue); err != nil { - return false, err - } - var rightValue interface{} - if err := json.Unmarshal([]byte(right), &rightValue); err != nil { - return false, err - } - leftCanonical, err := json.Marshal(leftValue) - if err != nil { - return false, err - } - rightCanonical, err := json.Marshal(rightValue) - if err != nil { - return false, err - } - return bytes.Equal(leftCanonical, rightCanonical), nil -} - -func decodeStyleExecutionPlan(value interface{}) (styleExecutionPlanDocument, error) { - if value == nil { - return styleExecutionPlanDocument{}, errors.New("项目画风执行计划缺失") - } - raw, err := json.Marshal(value) - if err != nil { - return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效") - } - var plan styleExecutionPlanDocument - if err := json.Unmarshal(raw, &plan); err != nil { - return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效") - } - return plan, nil -} - -func resolveGenerationStyleExecution(profile styleProfileDocument, generationModel string, interfaceType string) (string, string, []string) { - fragments := []string{strings.TrimSpace(profile.Prompt)} - if negative := strings.TrimSpace(profile.NegativePrompt); negative != "" { - fragments = append(fragments, "【全局负面 Prompt】\n"+negative) - } - warnings := make([]string, 0) - for _, asset := range profile.Assets { - if asset.Enabled != nil && !*asset.Enabled { - continue - } - if asset.Status != "validated" { - reason := "资产尚未验证" - if asset.Status == "unavailable" { - reason = "资产当前不可用" - } - warnings = append(warnings, asset.Title+":"+reason) - continue - } - if len(asset.BaseModels) > 0 && !styleAssetSupportsModel(asset.BaseModels, generationModel) { - warnings = append(warnings, asset.Title+":仅兼容 "+strings.Join(asset.BaseModels, "、")) - continue - } - switch asset.Kind { - case "prompt", "template": - fragments = append(fragments, strings.TrimSpace(asset.PromptFragment)) - fragments = append(fragments, nonEmptyStyleProfileStrings(asset.TriggerWords)...) - case "reference": - warnings = append(warnings, asset.Title+":项目参考图自动注入适配器尚未启用") - case "lora": - warnings = append(warnings, asset.Title+":当前 "+firstNonEmpty(interfaceType, "图片")+" 协议未启用 LoRA 适配器") - } - } - normalizedFragments := nonEmptyStyleProfileStrings(fragments) - status := "ready" - if len(warnings) > 0 { - status = "degraded" - if profile.ExecutionPolicy == "strict-assets" { - status = "blocked" - } - } - return strings.Join(normalizedFragments, "\n"), status, warnings -} - -func styleAssetSupportsModel(baseModels []string, generationModel string) bool { - for _, baseModel := range baseModels { - if strings.EqualFold(strings.TrimSpace(baseModel), strings.TrimSpace(generationModel)) { - return true - } - } - return false -} - -func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) error { - fallback := DefaultImageCapabilityConfig(input.Config.InterfaceType, input.Config.Model) - channelID := strings.TrimSpace(input.Config.ChannelID) - if channelID == "" { - if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Image != nil { - input.ImageCapability = input.Config.CapabilityConfig.Image - } else { - input.ImageCapability = fallback - } - return validateImageTask(input.ImageCapability, *input) - } - item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config)) - if err != nil { - return errors.New("当前系统渠道模型未配置或已停用") - } - profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) - if err != nil { - return errors.New("当前图片模型能力参数无效") - } - if profile != nil && profile.Image != nil { - input.ImageCapability = profile.Image - } else { - input.ImageCapability = fallback - } - return validateImageTask(input.ImageCapability, *input) -} - -func metadataStringValues(value any) map[string]string { - values := map[string]string{} - raw, ok := value.(map[string]interface{}) - if !ok { - return values - } - for key, item := range raw { - values[key] = strings.TrimSpace(fmt.Sprint(item)) - } - return values -} - -func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationInput, policy providerMediaHydrationPolicy) error { - groups := [][]providerMedia{input.ReferenceImages, input.ReferenceVideos, input.ReferenceAudios} - for groupIndex, group := range groups { - mediaPolicy := policy - // 仅 Agent 图片走内存字节;视频、音频及普通生成任务保留原来的协议策略。 - if groupIndex == 0 && input.Mode == "text" && input.AgentRequests != nil && input.AgentRequests.Canonical != nil { - mediaPolicy = providerMediaHydrationPolicy{imageOnly: true} - if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Text != nil { - limits := input.Config.CapabilityConfig.Text.References - if len(group) > limits.MaxImages { - return errors.New("参考图片数量超过当前模型限制") - } - mediaPolicy.maxBytes = limits.MaxImageBytes - } - } - for index := range group { - if err := s.hydrateProviderMedia(userID, &group[index], mediaPolicy); err != nil { - return err - } - } - } - if input.Mask != nil { - return s.hydrateProviderMedia(userID, input.Mask, policy) - } - return nil -} - -func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, policy providerMediaHydrationPolicy) error { - if !strings.HasPrefix(media.StorageKey, "resource:") { - if policy.requireURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { - return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到对象存储或提供公网素材地址") - } - return nil - } - resourceID := strings.TrimPrefix(media.StorageKey, "resource:") - resource, err := s.repo.ResourceForUser(userID, resourceID) - if err != nil { - return fmt.Errorf("读取任务参考资源失败:%w", err) - } - if resource.Status != "ready" { - return errors.New("任务参考资源尚未上传完成") - } - if policy.imageOnly && !strings.HasPrefix(strings.ToLower(resource.MimeType), "image/") { - return errors.New("看图资源不是图片") - } - if policy.maxBytes > 0 && resource.Size > policy.maxBytes { - return errors.New("参考图片文件超过当前模型大小限制") - } - useObjectURL := policy.requireURL || (policy.preferURL && resourceUsesObjectStorage(resource)) - if useObjectURL { - signedURL, err := s.providerResourceURL(resource, time.Time{}) - if err != nil { - return fmt.Errorf("生成参考素材地址失败:%w", err) - } - media.URL = signedURL - media.DataURL = "" - media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType) - media.Bytes = resource.Size - media.Width = resource.Width - media.Height = resource.Height - media.DurationMs = resource.DurationMs - return nil - } - // Agent 看图以归属校验后的资源文件为准,不能让附带的内嵌内容替换真实图片。 - if !policy.imageOnly && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { - return nil - } - resource, body, err := s.OpenResource(userID, resourceID) - if err != nil { - return fmt.Errorf("读取任务参考资源失败:%w", err) - } - defer body.Close() - runtimePolicy, err := s.RuntimePolicy() - if err != nil { - return err - } - resourceLimit := megabytes(runtimePolicy.Resource.ResourceUploadMB) - if policy.maxBytes > 0 { - resourceLimit = min(resourceLimit, policy.maxBytes) - } - data, err := io.ReadAll(io.LimitReader(body, resourceLimit+1)) - if err != nil { - return err - } - if int64(len(data)) > resourceLimit { - return fmt.Errorf("任务参考资源超过读取上限 %d 字节", resourceLimit) - } - if policy.imageOnly && len(data) == 0 { - return errors.New("看图资源内容为空") - } - mimeType := normalizedMediaMimeType(firstNonEmpty(media.MimeType, resource.MimeType), data) - media.DataURL = dataURL(mimeType, data) - media.MimeType = mimeType - media.Bytes = int64(len(data)) - media.Width = resource.Width - media.Height = resource.Height - media.DurationMs = resource.DurationMs - return nil -} - -func resourceUsesObjectStorage(resource *model.Resource) bool { - if resource == nil { - return false - } - provider := strings.ToLower(strings.TrimSpace(resource.Provider)) - return provider != "" && provider != "local" -} - -func normalizedMediaMimeType(declared string, data []byte) string { - declared = strings.TrimSpace(strings.Split(declared, ";")[0]) - if declared != "" && declared != "application/octet-stream" { - return declared - } - detected := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]) - return defaultString(detected, "application/octet-stream") -} - func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig, error) { headers, err := NormalizeOutboundHeaders(config.Headers) if err != nil { diff --git a/backend/internal/app/provider_errors.go b/backend/internal/app/provider_errors.go new file mode 100644 index 000000000..e0d7d3ada --- /dev/null +++ b/backend/internal/app/provider_errors.go @@ -0,0 +1,227 @@ +// 供应商调用的错误类型与用户可读文案。 +// +// 原始上游错误(状态码、响应体片段)只进诊断与调用日志;返回给用户的文案按类别归一, +// 不泄露密钥、内部地址或冗长响应体。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +type providerError struct { + Message string `json:"message"` +} + +// providerPayloadError 在进程内保留上游原始原因,供协议兼容分支做机器判断; +// 对调用方只暴露经过过滤的错误原因。Provider 正文可能包含密钥或内部诊断, +// 禁止原样进入用户错误和日志。 +type providerPayloadError struct { + raw string + message string +} + +func (e providerPayloadError) Error() string { return e.message } + +type providerHTTPError struct { + StatusCode int + Status string + Body string + RetryAfter time.Duration +} + +type providerResponseDecodeError struct { + Err error +} + +func (e providerResponseDecodeError) Error() string { return e.Err.Error() } + +func (e providerResponseDecodeError) Unwrap() error { return e.Err } + +type providerCircuitOpenError struct{} + +func (providerCircuitOpenError) Error() string { + return "当前渠道连续失败,已暂时熔断,请稍后重试" +} + +type providerStatePendingError struct { + TaskID string + Cause error +} + +func (e providerStatePendingError) Error() string { + return fmt.Sprintf("上游任务状态尚未同步,将继续查询原任务(任务 %s)", e.TaskID) +} + +func (e providerStatePendingError) Unwrap() error { return e.Cause } + +type providerAnalyticsKey struct{} + +type providerAnalyticsContext struct { + Service *Service + Billing taskBillingLifecycle + UserID string + TaskID string + TraceID string + RequestID string + BillingOrderID string + BillingMode string + Capability string + Operation string + ChannelID string + Model string + VideoSeconds int + RequestKind string + ProviderRequestID string + ConcurrencyLimit int +} + +func withProviderAnalytics(ctx context.Context, service *Service, task model.Task) context.Context { + metadata := providerAnalyticsContext{Service: service, UserID: task.UserID, TaskID: task.ID, TraceID: task.TraceID, RequestID: task.RequestID, BillingOrderID: task.BillingOrderID, Capability: capabilityFromTaskType(task.Type), Operation: task.Operation, Model: task.Model, ProviderRequestID: task.ProviderRequestID} + if service != nil { + metadata.Billing = service.taskBilling() + } + // 账单模式随请求上下文传递,流式协议据此只为 Token 计费开启 usage 终态块。 + if service != nil && task.BillingOrderID != "" { + if order, err := service.repo.BillingOrder(task.BillingOrderID); err == nil { + metadata.BillingMode = order.BillingMode + } + } + var input struct { + Mode string `json:"mode"` + Config providerConfig `json:"config"` + } + if json.Unmarshal([]byte(task.InputJSON), &input) == nil { + metadata.ChannelID = firstNonEmpty(input.Config.ChannelID, systemChannelIDFromBaseURL(input.Config.BaseURL)) + metadata.Model = firstNonEmpty(input.Config.ChannelModelKey, input.Config.Model, metadata.Model) + metadata.VideoSeconds, _ = strconv.Atoi(input.Config.VideoSeconds) + if normalized := normalizeCapability(input.Mode); normalized != "" { + metadata.Capability = normalized + } + } + return context.WithValue(ctx, providerAnalyticsKey{}, metadata) +} + +func resumedProviderRequestID(ctx context.Context) string { + metadata, _ := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + return strings.TrimSpace(metadata.ProviderRequestID) +} + +func withProviderRequestKind(ctx context.Context, requestKind string) context.Context { + metadata, ok := ctx.Value(providerAnalyticsKey{}).(providerAnalyticsContext) + if !ok { + return ctx + } + metadata.RequestKind = requestKind + return context.WithValue(ctx, providerAnalyticsKey{}, metadata) +} + +func (e providerHTTPError) Error() string { + if e.StatusCode == http.StatusBadRequest || e.StatusCode == http.StatusUnprocessableEntity { + return providerErrorWithDetail(e.summary(), e.Body) + } + return appendProviderErrorDetail(e.summary(), e.Body) +} + +func (e providerHTTPError) summary() string { + switch e.StatusCode { + case 524: + return "上游网关超时(524):模型请求可能仍在服务端执行并产生费用,请勿立即重试,请先到供应商后台核对任务或账单" + case http.StatusBadRequest, http.StatusUnprocessableEntity: + return "模型服务拒绝了请求,请检查模型和参数" + case http.StatusUnauthorized, http.StatusForbidden: + return "模型服务鉴权失败,请检查 API Key 和模型权限" + case http.StatusNotFound: + return "模型或模型接口不存在,请检查渠道配置" + case http.StatusRequestTimeout, http.StatusGatewayTimeout: + return "模型服务响应超时,请稍后重试" + case http.StatusTooManyRequests: + return "模型服务请求过于频繁或额度不足,请稍后重试" + } + if e.StatusCode >= http.StatusInternalServerError { + return fmt.Sprintf("模型服务暂时不可用(HTTP %d)", e.StatusCode) + } + return fmt.Sprintf("模型服务请求失败(HTTP %d)", e.StatusCode) +} + +func providerUserFacingErrorMessage(err error) string { + if err == nil { + return "模型服务请求失败" + } + if errors.Is(err, context.Canceled) { + return "模型请求已取消" + } + if errors.Is(err, context.DeadlineExceeded) { + return "模型服务响应超时,请稍后重试" + } + var appErr *AppError + if errors.As(err, &appErr) && strings.TrimSpace(appErr.Message) != "" { + return appErr.Message + } + var httpErr providerHTTPError + if errors.As(err, &httpErr) { + return httpErr.Error() + } + return "连接模型服务失败,请检查渠道地址和网络" +} + +// providerPayloadErrorCategory 把上游失败正文归类为固定的用户可见原因。 +// 第二个返回值为 false 表示正文无法归类,调用方应退回到更通用的提示, +// 不要因为归类失败就把正文本身当作错误信息。 +// 正文可能包含密钥或内部诊断信息,只能参与归类,不得回传用户或写入日志。 +func providerPayloadErrorCategory(raw string) (string, bool) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return "", false + } + switch { + // 真人肖像类目只匹配供应商错误码里的稳定标识,不扫描自然语言。 + // 正文常常回显用户提示词,"likeness"、"肖像"这类词单独出现并不能证明 + // 上游是因为真人形象拒绝,按词判断会把普通参数错误误报成肖像问题。 + // 该类目排在安全审核之前:错误码已经足够具体,比通用审核提示更可行动。 + case strings.Contains(normalized, "privacyinformation"), strings.Contains(normalized, "sensitivecontentdetected"): + return "输入素材疑似包含真人形象,该模型拒绝生成,请更换为非真人素材或改用其他模型", true + case strings.Contains(normalized, "safety"), strings.Contains(normalized, "moderation"), strings.Contains(normalized, "content policy"), strings.Contains(normalized, "blocked"): + return "请求内容未通过模型服务安全审核,请调整后重试", true + // 工具调用与工具结果不配对:上游要求 assistant 消息声明的每一个 tool_call_id 都在 + // 紧随其后的 tool 消息里被回应。这是**我们组装请求**的问题——用户改提示词或查额度 + // 都没用——所以文案指向反馈而不是"调整输入"。 + // + // 必须排在额度类目之前:DeepSeek 的原文含 "insufficient", + // "An assistant message with 'tool_calls' must be followed by tool messages + // responding to each 'tool_call_id'. (insufficient tool messages following + // tool_calls message)" 落到额度类目就会把协议错误报成"渠道余额不足", + // 掩盖真正的原因(历史里的工具结果不连续)。 + case strings.Contains(normalized, "must be followed by tool messages"), + strings.Contains(normalized, "insufficient tool messages following"): + return "会话里的工具调用与结果不匹配,本轮已停止;这不是额度或提示词问题,如反复出现请反馈", true + case strings.Contains(normalized, "quota"), strings.Contains(normalized, "insufficient"), strings.Contains(normalized, "balance"), strings.Contains(normalized, "billing"): + return "模型服务额度不足,请检查渠道余额或配额", true + case strings.Contains(normalized, "model") && (strings.Contains(normalized, "not found") || strings.Contains(normalized, "permission") || strings.Contains(normalized, "access")): + return "模型不存在或当前渠道未获得模型权限", true + // 推理/思考模式模型通常禁止强制指定工具调用:DeepSeek 思考模式返回 + // "Thinking mode does not support this tool_choice",其他 OpenAI 兼容 + // 供应商措辞类似。归为固定可行动原因;显式思考模式会在出站前省略 + // tool_choice,未声明但由上游隐式开启思考时再按兼容序列重试。排在 + // 通用参数类目之前,避免稳定标识落回笼统的"请检查模型和参数"。 + case (strings.Contains(normalized, "thinking") || strings.Contains(normalized, "reasoning")) && strings.Contains(normalized, "tool_choice"), + strings.Contains(normalized, "tool_choice") && (strings.Contains(normalized, "not support") || strings.Contains(normalized, "unsupported")): + return "当前模型为思考/推理模式,不支持强制工具调用(tool_choice=required),请改用自动工具选择或更换非思考模式模型", true + case strings.Contains(normalized, "invalid"), strings.Contains(normalized, "parameter"), strings.Contains(normalized, "argument"): + return "模型服务拒绝了请求,请检查模型和参数", true + } + return "", false +} + +func providerPayloadErrorMessage(raw string) string { + return providerErrorWithDetail("模型服务返回失败,请检查请求内容或渠道配置", raw) +} diff --git a/backend/internal/app/provider_media_hydration.go b/backend/internal/app/provider_media_hydration.go new file mode 100644 index 000000000..0640ff458 --- /dev/null +++ b/backend/internal/app/provider_media_hydration.go @@ -0,0 +1,133 @@ +// 生成输入媒体的准备:把画布上的资源引用转换成供应商可读取的形式(签名 URL 或 data URL)。 +// +// 供应商能读取公网地址时优先给签名 URL;不能时才内联为 data URL,避免请求体过大。 + +package app + +import ( + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "infinite-canvas/backend/internal/model" +) + +func (s *Service) hydrateGenerationMedia(userID string, input *canvasGenerationInput, policy providerMediaHydrationPolicy) error { + groups := [][]providerMedia{input.ReferenceImages, input.ReferenceVideos, input.ReferenceAudios} + for groupIndex, group := range groups { + mediaPolicy := policy + // 仅 Agent 图片走内存字节;视频、音频及普通生成任务保留原来的协议策略。 + if groupIndex == 0 && input.Mode == "text" && input.AgentRequests != nil && input.AgentRequests.Canonical != nil { + mediaPolicy = providerMediaHydrationPolicy{imageOnly: true} + if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Text != nil { + limits := input.Config.CapabilityConfig.Text.References + if len(group) > limits.MaxImages { + return errors.New("参考图片数量超过当前模型限制") + } + mediaPolicy.maxBytes = limits.MaxImageBytes + } + } + for index := range group { + if err := s.hydrateProviderMedia(userID, &group[index], mediaPolicy); err != nil { + return err + } + } + } + if input.Mask != nil { + return s.hydrateProviderMedia(userID, input.Mask, policy) + } + return nil +} + +func (s *Service) hydrateProviderMedia(userID string, media *providerMedia, policy providerMediaHydrationPolicy) error { + if !strings.HasPrefix(media.StorageKey, "resource:") { + if policy.requireURL && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { + return errors.New("当前 JSON 视频协议的参考素材不能使用内嵌数据,请先上传到对象存储或提供公网素材地址") + } + return nil + } + resourceID := strings.TrimPrefix(media.StorageKey, "resource:") + resource, err := s.repo.ResourceForUser(userID, resourceID) + if err != nil { + return fmt.Errorf("读取任务参考资源失败:%w", err) + } + if resource.Status != "ready" { + return errors.New("任务参考资源尚未上传完成") + } + if policy.imageOnly && !strings.HasPrefix(strings.ToLower(resource.MimeType), "image/") { + return errors.New("看图资源不是图片") + } + if policy.maxBytes > 0 && resource.Size > policy.maxBytes { + return errors.New("参考图片文件超过当前模型大小限制") + } + useObjectURL := policy.requireURL || (policy.preferURL && resourceUsesObjectStorage(resource)) + if useObjectURL { + signedURL, err := s.providerResourceURL(resource, time.Time{}) + if err != nil { + return fmt.Errorf("生成参考素材地址失败:%w", err) + } + media.URL = signedURL + media.DataURL = "" + media.MimeType = firstNonEmpty(media.MimeType, resource.MimeType) + media.Bytes = resource.Size + media.Width = resource.Width + media.Height = resource.Height + media.DurationMs = resource.DurationMs + return nil + } + // Agent 看图以归属校验后的资源文件为准,不能让附带的内嵌内容替换真实图片。 + if !policy.imageOnly && strings.HasPrefix(strings.TrimSpace(media.DataURL), "data:") { + return nil + } + resource, body, err := s.OpenResource(userID, resourceID) + if err != nil { + return fmt.Errorf("读取任务参考资源失败:%w", err) + } + defer body.Close() + runtimePolicy, err := s.RuntimePolicy() + if err != nil { + return err + } + resourceLimit := megabytes(runtimePolicy.Resource.ResourceUploadMB) + if policy.maxBytes > 0 { + resourceLimit = min(resourceLimit, policy.maxBytes) + } + data, err := io.ReadAll(io.LimitReader(body, resourceLimit+1)) + if err != nil { + return err + } + if int64(len(data)) > resourceLimit { + return fmt.Errorf("任务参考资源超过读取上限 %d 字节", resourceLimit) + } + if policy.imageOnly && len(data) == 0 { + return errors.New("看图资源内容为空") + } + mimeType := normalizedMediaMimeType(firstNonEmpty(media.MimeType, resource.MimeType), data) + media.DataURL = dataURL(mimeType, data) + media.MimeType = mimeType + media.Bytes = int64(len(data)) + media.Width = resource.Width + media.Height = resource.Height + media.DurationMs = resource.DurationMs + return nil +} + +func resourceUsesObjectStorage(resource *model.Resource) bool { + if resource == nil { + return false + } + provider := strings.ToLower(strings.TrimSpace(resource.Provider)) + return provider != "" && provider != "local" +} + +func normalizedMediaMimeType(declared string, data []byte) string { + declared = strings.TrimSpace(strings.Split(declared, ";")[0]) + if declared != "" && declared != "application/octet-stream" { + return declared + } + detected := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0]) + return defaultString(detected, "application/octet-stream") +} diff --git a/backend/internal/app/provider_protocol.go b/backend/internal/app/provider_protocol.go index 80cdfc3e9..50c1ce802 100644 --- a/backend/internal/app/provider_protocol.go +++ b/backend/internal/app/provider_protocol.go @@ -3,30 +3,15 @@ package app // 声明式协议插件宿主与接口类型校验。 import ( - "bytes" "context" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" "encoding/json" "errors" "fmt" - "io" - "mime/multipart" - "net" - "net/http" - "net/textproto" - "net/url" - "sort" - "strconv" "strings" "time" - "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/protocol" - "github.com/google/uuid" - "github.com/volcengine/volc-sdk-golang/base" + "infinite-canvas/backend/internal/protocol" ) // runDeclarativeProtocolTask 是 JSON manifest 插件的宿主运行时。 @@ -184,771 +169,6 @@ func queryProtocolAdapterVideoTask(ctx context.Context, input canvasGenerationIn } } -func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRequest { - resolution := strings.TrimSpace(input.Config.VQuality) - if input.Mode == "video" { - if declared := videoResolutionNameRequest(input.VideoCapability, resolution); declared != "" { - resolution = declared - } - } - aspectRatio := input.Config.Size - if input.Mode == "image" && strings.TrimSpace(input.Config.InterfaceType) == string(model.ChannelInterfaceOpenAIImage) { - aspectRatio = normalizePixelSize(aspectRatio) - } - request := protocol.GenerationRequest{ - Capability: protocol.Capability(input.Mode), - Model: input.Config.Model, - Prompt: input.Prompt, - Instructions: strings.TrimSpace(input.Config.SystemPrompt), - Images: protocolImageReferences(input), - Videos: protocolMediaReferences(input.ReferenceVideos, "video"), - Audios: protocolMediaReferences(input.ReferenceAudios, "audio"), - AspectRatio: aspectRatio, - Resolution: resolution, - Quality: input.Config.Quality, - GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false), - Watermark: parseBool(input.Config.VideoWatermark, false), - Operation: firstNonEmpty(metadataString(input.Metadata, "videoEditOperation"), metadataString(input.Metadata, "videoOperation")), - Extra: map[string]any{ - "videoSeconds": input.Config.VideoSeconds, - "audioVoice": input.Config.AudioVoice, - "audioFormat": input.Config.AudioFormat, - "count": input.Config.Count, - }, - } - for _, message := range input.TextHistory { - role := strings.ToLower(strings.TrimSpace(message.Role)) - if role != "user" && role != "assistant" && role != "system" { - continue - } - if content := strings.TrimSpace(message.Content); content != "" { - request.Messages = append(request.Messages, protocol.Message{Role: role, Content: content}) - } - } - request.Inputs = append(request.Inputs, request.Images...) - request.Inputs = append(request.Inputs, request.Videos...) - request.Inputs = append(request.Inputs, request.Audios...) - if input.MaxOutputTokens > 0 { - request.Extra["max_output_tokens"] = input.MaxOutputTokens - request.Extra["max_tokens"] = input.MaxOutputTokens - } - if duration, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)); err == nil && duration > 0 { - request.Duration = duration - } - if count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)); err == nil && count > 0 { - request.ImageCount = count - } - request.Output = protocol.OutputOptions{ - Count: request.ImageCount, Duration: request.Duration, AspectRatio: request.AspectRatio, - Resolution: request.Resolution, Quality: request.Quality, GenerateAudio: request.GenerateAudio, - Watermark: request.Watermark, Format: input.Config.AudioFormat, - } - request.ProviderOptions = make(map[string]map[string]any) - if configured, ok := input.Metadata["providerOptions"].(map[string]any); ok { - for namespace, raw := range configured { - if options, ok := raw.(map[string]any); ok { - request.ProviderOptions[strings.TrimSpace(namespace)] = options - } - } - } - return request -} - -func protocolImageReferences(input canvasGenerationInput) []protocol.MediaReference { - if input.Mode == "video" { - return protocolVideoImageReferences(input) - } - result := make([]protocol.MediaReference, 0, len(input.ReferenceImages)+1) - for index, value := range input.ReferenceImages { - item := protocolMediaReference(value, "image", index) - item.Role = "reference_image" - if input.Mode == "image" { - item.Role = "edit_source" - } - if item.URL != "" || item.DataURL != "" { - result = append(result, item) - } - } - if input.Mask != nil { - mask := protocolMediaReference(*input.Mask, "image", len(result)) - mask.Role = "mask" - if mask.URL != "" || mask.DataURL != "" { - result = append(result, mask) - } - } - return result -} - -func protocolVideoImageReferences(input canvasGenerationInput) []protocol.MediaReference { - result := make([]protocol.MediaReference, 0, len(input.ReferenceImages)) - fallbackRole := "" - if metadataString(input.Metadata, "videoStartFrameNodeId") != "" || metadataString(input.Metadata, "videoEndFrameNodeId") != "" { - fallbackRole = "reference_image" - } - for index, value := range input.ReferenceImages { - item := protocolMediaReference(value, "image", index) - item.Role = videoImageRoleOrDefault(input, value, fallbackRole) - if item.URL != "" || item.DataURL != "" { - result = append(result, item) - } - } - return result -} - -func protocolMediaReferences(values []providerMedia, kind string) []protocol.MediaReference { - result := make([]protocol.MediaReference, 0, len(values)) - for index, value := range values { - item := protocolMediaReference(value, kind, index) - if kind == "video" { - item.Role = "reference_video" - } else if kind == "audio" { - item.Role = "reference_audio" - } - if item.URL != "" || item.DataURL != "" { - result = append(result, item) - } - } - return result -} - -func protocolMediaReference(value providerMedia, kind string, order int) protocol.MediaReference { - return protocol.MediaReference{ - ID: strings.TrimSpace(value.ID), URL: strings.TrimSpace(value.URL), DataURL: strings.TrimSpace(value.DataURL), - Kind: kind, MIMEType: firstNonEmpty(strings.TrimSpace(value.MimeType), strings.TrimSpace(value.Type)), Name: strings.TrimSpace(value.Name), Order: order, - Metadata: map[string]any{"bytes": value.Bytes, "width": value.Width, "height": value.Height, "durationMs": value.DurationMs, "storageKey": strings.TrimSpace(value.StorageKey)}, - } -} - -func executeProtocolRequest(ctx context.Context, config providerConfig, spec protocol.RequestSpec) ([]byte, error) { - data, _, err := executeProtocolBinaryRequest(ctx, config, spec) - return data, err -} - -// executeProtocolBinaryRequest 是声明式插件与宿主网络能力之间的边界。manifest 只能声明 -// method/path/body/auth;最终 URL 校验、凭证注入、SSRF、超时、大小限制和审计仍由宿主统一执行, -// 插件不能通过自定义请求规格绕过这些安全约束。 -func executeProtocolBinaryRequest(ctx context.Context, config providerConfig, spec protocol.RequestSpec) ([]byte, string, error) { - return executeProtocolBinaryRequestWithConsumer(ctx, config, spec, nil) -} - -func executeProtocolBinaryRequestWithConsumer(ctx context.Context, config providerConfig, spec protocol.RequestSpec, consume func(string, []byte)) ([]byte, string, error) { - if err := spec.Validate(); err != nil { - return nil, "", err - } - method := strings.ToUpper(strings.TrimSpace(spec.Method)) - body, contentType, err := protocolRequestBody(ctx, config, spec) - if err != nil { - return nil, "", err - } - requestURL, err := protocolRequestURL(config.BaseURL, spec) - if err != nil { - return nil, "", err - } - req, err := http.NewRequestWithContext(ctx, method, requestURL, body) - if err != nil { - return nil, "", err - } - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - for name, value := range spec.Headers { - req.Header.Set(name, value) - } - ApplyOutboundHeaders(req, config.Headers) - if err := applyProtocolAuth(req, config, spec.Auth); err != nil { - return nil, "", err - } - if consume != nil { - req.Header.Set("Accept", "text/event-stream") - return doBinaryWithConsumer(req, consume) - } - return doBinary(req) -} - -func protocolRequestBody(ctx context.Context, config providerConfig, spec protocol.RequestSpec) (io.Reader, string, error) { - contentType := strings.ToLower(strings.TrimSpace(strings.Split(spec.ContentType, ";")[0])) - if spec.Body == nil && len(spec.Files) == 0 { - return nil, "", nil - } - switch contentType { - case "", "application/json": - data, err := json.Marshal(spec.Body) - if err != nil { - return nil, "", err - } - return bytes.NewReader(data), "application/json", nil - case "application/x-www-form-urlencoded": - values := url.Values{} - for key, value := range protocolBodyObject(spec.Body) { - for _, item := range protocolFormValues(value) { - values.Add(key, item) - } - } - return strings.NewReader(values.Encode()), contentType, nil - case "multipart/form-data": - var body bytes.Buffer - writer := multipart.NewWriter(&body) - keys := make([]string, 0) - for key := range protocolBodyObject(spec.Body) { - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - for _, item := range protocolFormValues(protocolBodyObject(spec.Body)[key]) { - if err := writer.WriteField(key, item); err != nil { - _ = writer.Close() - return nil, "", err - } - } - } - for _, file := range spec.Files { - data, detectedMIME, err := protocolMediaBytes(ctx, config, file.Reference) - if err != nil { - _ = writer.Close() - return nil, "", fmt.Errorf("读取 multipart 文件 %s 失败:%w", file.Name, err) - } - filename := safeProtocolFilename(file.Filename) - mimeType := strings.TrimSpace(file.MIMEType) - if mimeType == "" { - mimeType = detectedMIME - } - header := make(textproto.MIMEHeader) - header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, file.Name, filename)) - header.Set("Content-Type", defaultString(mimeType, "application/octet-stream")) - part, err := writer.CreatePart(header) - if err != nil { - _ = writer.Close() - return nil, "", err - } - if _, err := part.Write(data); err != nil { - _ = writer.Close() - return nil, "", err - } - } - if err := writer.Close(); err != nil { - return nil, "", err - } - return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), nil - case "application/octet-stream": - switch value := spec.Body.(type) { - case []byte: - return bytes.NewReader(value), contentType, nil - case string: - if strings.HasPrefix(value, "data:") { - mimeType, data, err := decodeProviderDataURL(value) - if err != nil { - return nil, "", err - } - return bytes.NewReader(data), defaultString(mimeType, contentType), nil - } - return strings.NewReader(value), contentType, nil - default: - return nil, "", fmt.Errorf("二进制协议请求体必须是字节或字符串") - } - default: - return nil, "", fmt.Errorf("声明式协议暂不支持 %s 请求体", spec.ContentType) - } -} - -func protocolBodyObject(value any) map[string]any { - result, _ := value.(map[string]any) - return result -} - -func protocolFormValues(value any) []string { - switch typed := value.(type) { - case nil: - return nil - case []any: - result := make([]string, 0, len(typed)) - for _, item := range typed { - result = append(result, protocolFormValues(item)...) - } - return result - case string: - return []string{typed} - case bool: - return []string{strconv.FormatBool(typed)} - case float64: - return []string{strconv.FormatFloat(typed, 'f', -1, 64)} - case int: - return []string{strconv.Itoa(typed)} - default: - data, err := json.Marshal(typed) - if err != nil { - return nil - } - return []string{string(data)} - } -} - -func safeProtocolFilename(value string) string { - value = strings.TrimSpace(value) - if index := strings.LastIndexAny(value, `/\\`); index >= 0 { - value = value[index+1:] - } - value = strings.Map(func(r rune) rune { - if r < 32 || r == 127 || r == '"' { - return -1 - } - return r - }, value) - if value == "" { - return "upload.bin" - } - return value -} - -func applyProtocolAuth(req *http.Request, config providerConfig, auth protocol.ManifestAuth) error { - typeName := strings.ToLower(strings.TrimSpace(auth.Type)) - if typeName == "" { - applyProviderAuth(req, config) - return nil - } - credential := protocolCredentialField(config, auth.Field) - switch typeName { - case "none": - return nil - case "bearer": - header := defaultString(strings.TrimSpace(auth.Header), "Authorization") - prefix := auth.Prefix - if prefix == "" { - prefix = "Bearer " - } - req.Header.Set(header, prefix+credential) - return nil - case "header", "api-key", "apikey": - header := strings.TrimSpace(auth.Header) - if header == "" { - return errors.New("插件 header 鉴权缺少 header 名称") - } - req.Header.Set(header, auth.Prefix+credential) - return nil - case "query": - name := defaultString(strings.TrimSpace(auth.Query), strings.TrimSpace(auth.Field)) - if name == "" { - return errors.New("插件 query 鉴权缺少参数名") - } - query := req.URL.Query() - query.Set(name, auth.Prefix+credential) - req.URL.RawQuery = query.Encode() - return nil - case "basic": - username := auth.Username - if username == "" { - username = credential - } - password := protocolCredentialField(config, auth.SecretField) - req.SetBasicAuth(username, password) - return nil - case "anthropic": - req.Header.Set(defaultString(auth.Header, "x-api-key"), credential) - if req.Header.Get("anthropic-version") == "" { - req.Header.Set("anthropic-version", "2023-06-01") - } - return nil - case "google-api-key", "gemini": - req.Header.Set(defaultString(auth.Header, "x-goog-api-key"), credential) - return nil - case "volcengine-v4": - secret := protocolCredentialField(config, auth.SecretField) - if credential == "" || secret == "" { - return errors.New("火山引擎 V4 鉴权需要 Access Key 和 Secret Key") - } - credentials := base.Credentials{ - AccessKeyID: credential, SecretAccessKey: secret, - Region: defaultString(strings.TrimSpace(auth.Region), "cn-north-1"), - Service: strings.TrimSpace(auth.Service), - } - if credentials.Service == "" { - return errors.New("火山引擎 V4 鉴权缺少 service") - } - signed := credentials.Sign(req) - *req = *signed - return nil - case "aws-sigv4": - secret := protocolCredentialField(config, auth.SecretField) - return signProtocolAWSV4(req, credential, secret, auth) - case "tc3": - secret := protocolCredentialField(config, auth.SecretField) - return signProtocolTC3(req, credential, secret, auth) - default: - return fmt.Errorf("插件声明了尚未启用的鉴权驱动 %s", auth.Type) - } -} - -func signProtocolAWSV4(req *http.Request, accessKey, secretKey string, auth protocol.ManifestAuth) error { - if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" { - return errors.New("AWS SigV4 鉴权需要 Access Key ID 和 Secret Access Key") - } - serviceName := defaultString(strings.TrimSpace(auth.Service), "bedrock") - region := strings.TrimSpace(auth.Region) - if region == "" { - parts := strings.Split(strings.ToLower(req.URL.Hostname()), ".") - for index, part := range parts { - if strings.HasPrefix(part, serviceName) && index+1 < len(parts) { - region = parts[index+1] - break - } - } - } - if region == "" { - return errors.New("AWS SigV4 鉴权无法从 Base URL 推断 region,请使用包含区域的 Bedrock Runtime 地址") - } - payload, err := protocolRequestPayload(req) - if err != nil { - return err - } - now := time.Now().UTC() - amzDate := now.Format("20060102T150405Z") - dateStamp := now.Format("20060102") - payloadHash := sha256Hex(payload) - req.Header.Set("X-Amz-Date", amzDate) - req.Header.Set("X-Amz-Content-Sha256", payloadHash) - canonicalHeaders, signedHeaders := protocolCanonicalHeaders(req) - canonicalRequest := strings.Join([]string{ - req.Method, - defaultString(req.URL.EscapedPath(), "/"), - req.URL.Query().Encode(), - canonicalHeaders, - signedHeaders, - payloadHash, - }, "\n") - scope := strings.Join([]string{dateStamp, region, serviceName, "aws4_request"}, "/") - stringToSign := strings.Join([]string{"AWS4-HMAC-SHA256", amzDate, scope, sha256Hex([]byte(canonicalRequest))}, "\n") - dateKey := protocolHMAC([]byte("AWS4"+secretKey), dateStamp) - regionKey := protocolHMAC(dateKey, region) - serviceKey := protocolHMAC(regionKey, serviceName) - signingKey := protocolHMAC(serviceKey, "aws4_request") - signature := hex.EncodeToString(protocolHMAC(signingKey, stringToSign)) - req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", accessKey, scope, signedHeaders, signature)) - return nil -} - -func signProtocolTC3(req *http.Request, secretID, secretKey string, auth protocol.ManifestAuth) error { - if strings.TrimSpace(secretID) == "" || strings.TrimSpace(secretKey) == "" { - return errors.New("腾讯云 TC3 鉴权需要 SecretId 和 SecretKey") - } - serviceName := defaultString(strings.TrimSpace(auth.Service), "hunyuan") - payload, err := protocolRequestPayload(req) - if err != nil { - return err - } - now := time.Now().UTC() - timestamp := now.Unix() - dateStamp := now.Format("2006-01-02") - contentType := defaultString(req.Header.Get("Content-Type"), "application/json") - req.Header.Set("Content-Type", contentType) - req.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) - if region := strings.TrimSpace(auth.Region); region != "" { - req.Header.Set("X-TC-Region", region) - } - canonicalHeaders := "content-type:" + strings.ToLower(strings.TrimSpace(contentType)) + "\n" + "host:" + strings.ToLower(req.URL.Host) + "\n" - signedHeaders := "content-type;host" - canonicalRequest := strings.Join([]string{req.Method, defaultString(req.URL.EscapedPath(), "/"), req.URL.Query().Encode(), canonicalHeaders, signedHeaders, sha256Hex(payload)}, "\n") - scope := dateStamp + "/" + serviceName + "/tc3_request" - stringToSign := strings.Join([]string{"TC3-HMAC-SHA256", strconv.FormatInt(timestamp, 10), scope, sha256Hex([]byte(canonicalRequest))}, "\n") - secretDate := protocolHMAC([]byte("TC3"+secretKey), dateStamp) - secretService := protocolHMAC(secretDate, serviceName) - secretSigning := protocolHMAC(secretService, "tc3_request") - signature := hex.EncodeToString(protocolHMAC(secretSigning, stringToSign)) - req.Header.Set("Authorization", fmt.Sprintf("TC3-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", secretID, scope, signedHeaders, signature)) - return nil -} - -func protocolRequestPayload(req *http.Request) ([]byte, error) { - if req.Body == nil { - return nil, nil - } - var reader io.ReadCloser - var err error - if req.GetBody != nil { - reader, err = req.GetBody() - } else { - reader = req.Body - } - if err != nil { - return nil, err - } - data, err := io.ReadAll(reader) - if req.GetBody != nil { - _ = reader.Close() - } else { - req.Body = io.NopCloser(bytes.NewReader(data)) - } - return data, err -} - -func protocolCanonicalHeaders(req *http.Request) (string, string) { - values := map[string]string{"host": strings.ToLower(req.URL.Host)} - for name, entries := range req.Header { - lower := strings.ToLower(strings.TrimSpace(name)) - if lower == "authorization" || lower == "user-agent" || lower == "content-length" || lower == "expect" { - continue - } - cleaned := make([]string, 0, len(entries)) - for _, entry := range entries { - cleaned = append(cleaned, strings.Join(strings.Fields(entry), " ")) - } - values[lower] = strings.Join(cleaned, ",") - } - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - var canonical strings.Builder - for _, key := range keys { - canonical.WriteString(key) - canonical.WriteByte(':') - canonical.WriteString(values[key]) - canonical.WriteByte('\n') - } - return canonical.String(), strings.Join(keys, ";") -} - -func protocolHMAC(key []byte, value string) []byte { - mac := hmac.New(sha256.New, key) - _, _ = mac.Write([]byte(value)) - return mac.Sum(nil) -} - -func sha256Hex(value []byte) string { - hash := sha256.Sum256(value) - return hex.EncodeToString(hash[:]) -} - -func protocolCredentialField(config providerConfig, field string) string { - switch strings.ToLower(strings.TrimSpace(field)) { - case "secretkey", "secret_key", "secret": - return strings.TrimSpace(config.SecretKey) - default: - return strings.TrimSpace(config.APIKey) - } -} - -func protocolRequestURL(baseURL string, spec protocol.RequestSpec) (string, error) { - if !spec.OriginPath { - return appendProtocolQuery(apiURL(baseURL, spec.Path), spec.Query) - } - base, err := url.Parse(strings.TrimSpace(baseURL)) - if err != nil || base.Scheme == "" || base.Host == "" { - return "", fmt.Errorf("协议根路径请求的 Base URL 无效") - } - requestPath, err := url.Parse(spec.Path) - if err != nil || !strings.HasPrefix(requestPath.Path, "/") { - return "", fmt.Errorf("协议根路径请求必须使用绝对路径") - } - base.Path = requestPath.Path - base.RawPath = requestPath.RawPath - base.RawQuery = requestPath.RawQuery - base.Fragment = "" - return appendProtocolQuery(base.String(), spec.Query) -} - -func appendProtocolQuery(rawURL string, values map[string][]string) (string, error) { - if len(values) == 0 { - return rawURL, nil - } - parsed, err := url.Parse(rawURL) - if err != nil { - return "", err - } - query := parsed.Query() - for key, items := range values { - for _, item := range items { - query.Add(key, item) - } - } - parsed.RawQuery = query.Encode() - return parsed.String(), nil -} - -func finishProtocolResult(ctx context.Context, config providerConfig, mode string, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) { - if result == nil { - return nil, errors.New("声明式协议已完成但没有返回结果") - } - if mode == "text" { - output := map[string]interface{}{"mode": "text", "text": result.Text} - if strings.TrimSpace(result.Reasoning) != "" { - output["reasoning"] = result.Reasoning - } - return output, nil - } - var references []protocol.MediaReference - switch mode { - case "image": - references = result.Images - case "video": - references = result.Videos - case "audio": - references = result.Audios - default: - return nil, fmt.Errorf("声明式协议不支持生成模式 %s", mode) - } - if len(references) == 0 { - return nil, errors.New("声明式协议已完成但没有返回媒体地址") - } - if output, handled, err := recoverProtocolMedia(ctx, config, mode, references); handled { - return output, err - } - items := make([]interface{}, 0, len(references)) - for _, reference := range references { - var data []byte - var mimeType string - var err error - if mode == "video" { - data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, func(ctx context.Context) ([]byte, string, error) { - return protocolMediaBytesOnce(ctx, config, reference) - }) - } else { - data, mimeType, err = protocolMediaBytes(ctx, config, reference) - } - if err != nil { - return nil, err - } - item := map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType} - items = append(items, item) - } - switch mode { - case "image": - return map[string]interface{}{"mode": "image", "images": items}, nil - case "video": - return map[string]interface{}{"mode": "video", "video": items[0]}, nil - default: - return map[string]interface{}{"mode": "audio", "audio": items[0]}, nil - } -} - -// finishProtocolAdapterResult 优先消费 create/poll 已返回的内联或 URL 结果,只有插件明确声明独立结果端点时才下载。 -// 空响应或下载失败必须向上失败,不能生成伪素材;application/octet-stream 仅表示传输层未知类型, -// 不会把未知内容伪装成具体图片、视频或音频 MIME。 -func finishProtocolAdapterResult(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, request protocol.GenerationRequest, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) { - if protocolResultHasOutput(input.Mode, result) { - return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy) - } - resultAdapter, ok := adapter.(protocol.ResultAdapter) - capability, hasCapability := adapter.(protocol.ResultCapability) - if !ok || !hasCapability || !capability.ResultAvailable() { - return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy) - } - spec, err := resultAdapter.BuildResult(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}) - if err != nil { - return nil, err - } - download := func(ctx context.Context) ([]byte, string, error) { - return executeProtocolBinaryRequest(withProviderRequestKind(ctx, "download"), input.Config, spec) - } - var data []byte - var mimeType string - if input.Mode == "video" { - data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, download) - } else { - data, mimeType, err = download(ctx) - } - if err != nil { - return nil, fmt.Errorf("声明式协议结果下载失败:%w", err) - } - if len(data) == 0 { - return nil, errors.New("声明式协议结果下载返回空内容") - } - mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) - if mimeType == "" { - mimeType = "application/octet-stream" - } - reference := protocol.MediaReference{DataURL: dataURL(mimeType, data), MIMEType: mimeType} - downloaded := &protocol.Result{} - switch input.Mode { - case "image": - downloaded.Images = []protocol.MediaReference{reference} - case "video": - downloaded.Videos = []protocol.MediaReference{reference} - case "audio": - downloaded.Audios = []protocol.MediaReference{reference} - default: - return nil, fmt.Errorf("声明式协议结果下载不支持生成模式 %s", input.Mode) - } - return finishProtocolResult(ctx, input.Config, input.Mode, taskID, downloaded, pollPolicy) -} - -func protocolResultHasOutput(mode string, result *protocol.Result) bool { - if result == nil { - return false - } - switch mode { - case "text": - return strings.TrimSpace(result.Text) != "" - case "image": - return len(result.Images) > 0 - case "video": - return len(result.Videos) > 0 - case "audio": - return len(result.Audios) > 0 - default: - return false - } -} - -func protocolMediaBytes(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) { - var data []byte - var mimeType string - var err error - for attempt := 0; attempt < 3; attempt++ { - data, mimeType, err = protocolMediaBytesOnce(ctx, config, reference) - if err == nil { - return data, mimeType, nil - } - if attempt == 2 || !retryableProtocolMediaDownload(err) { - break - } - if waitErr := sleepContext(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil { - return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", waitErr) - } - } - return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", err) -} - -func protocolMediaBytesOnce(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) { - if strings.TrimSpace(reference.DataURL) != "" { - mimeType, data, err := decodeProviderDataURL(reference.DataURL) - return data, mimeType, err - } - value := strings.TrimSpace(reference.URL) - if value == "" { - return nil, "", errors.New("声明式协议媒体结果地址为空") - } - data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value) - return data, normalizedMediaMimeType(mimeType, data), err -} - -func retryableProtocolMediaDownload(err error) bool { - if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - return false - } - var networkError net.Error - if errors.As(err, &networkError) && (networkError.Timeout() || networkError.Temporary()) { - return true - } - message := strings.ToLower(err.Error()) - for _, marker := range []string{"tls handshake timeout", "connection reset", "unexpected eof", "broken pipe"} { - if strings.Contains(message, marker) { - return true - } - } - return false -} - -func protocolResultError(message, taskID string) error { - message = strings.TrimSpace(message) - if message == "" { - message = "上游返回失败状态" - } - if taskID == "" { - return errors.New(message) - } - return fmt.Errorf("声明式协议任务失败(任务 %s):%s", taskID, message) -} - func validateGenerationInterface(mode string, interfaceType string) error { // Standalone callers (including legacy tests and migration tools) may not // have a Service/plugin runtime. Use the shipped declarative catalog first; diff --git a/backend/internal/app/provider_protocol_auth.go b/backend/internal/app/provider_protocol_auth.go new file mode 100644 index 000000000..bdef42f82 --- /dev/null +++ b/backend/internal/app/provider_protocol_auth.go @@ -0,0 +1,269 @@ +// 声明式协议插件的鉴权:Bearer/Header/Query、AWS SigV4 与腾讯云 TC3 签名。 + +package app + +import ( + "bytes" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/volcengine/volc-sdk-golang/base" + "infinite-canvas/backend/internal/protocol" +) + +func applyProtocolAuth(req *http.Request, config providerConfig, auth protocol.ManifestAuth) error { + typeName := strings.ToLower(strings.TrimSpace(auth.Type)) + if typeName == "" { + applyProviderAuth(req, config) + return nil + } + credential := protocolCredentialField(config, auth.Field) + switch typeName { + case "none": + return nil + case "bearer": + header := defaultString(strings.TrimSpace(auth.Header), "Authorization") + prefix := auth.Prefix + if prefix == "" { + prefix = "Bearer " + } + req.Header.Set(header, prefix+credential) + return nil + case "header", "api-key", "apikey": + header := strings.TrimSpace(auth.Header) + if header == "" { + return errors.New("插件 header 鉴权缺少 header 名称") + } + req.Header.Set(header, auth.Prefix+credential) + return nil + case "query": + name := defaultString(strings.TrimSpace(auth.Query), strings.TrimSpace(auth.Field)) + if name == "" { + return errors.New("插件 query 鉴权缺少参数名") + } + query := req.URL.Query() + query.Set(name, auth.Prefix+credential) + req.URL.RawQuery = query.Encode() + return nil + case "basic": + username := auth.Username + if username == "" { + username = credential + } + password := protocolCredentialField(config, auth.SecretField) + req.SetBasicAuth(username, password) + return nil + case "anthropic": + req.Header.Set(defaultString(auth.Header, "x-api-key"), credential) + if req.Header.Get("anthropic-version") == "" { + req.Header.Set("anthropic-version", "2023-06-01") + } + return nil + case "google-api-key", "gemini": + req.Header.Set(defaultString(auth.Header, "x-goog-api-key"), credential) + return nil + case "volcengine-v4": + secret := protocolCredentialField(config, auth.SecretField) + if credential == "" || secret == "" { + return errors.New("火山引擎 V4 鉴权需要 Access Key 和 Secret Key") + } + credentials := base.Credentials{ + AccessKeyID: credential, SecretAccessKey: secret, + Region: defaultString(strings.TrimSpace(auth.Region), "cn-north-1"), + Service: strings.TrimSpace(auth.Service), + } + if credentials.Service == "" { + return errors.New("火山引擎 V4 鉴权缺少 service") + } + signed := credentials.Sign(req) + *req = *signed + return nil + case "aws-sigv4": + secret := protocolCredentialField(config, auth.SecretField) + return signProtocolAWSV4(req, credential, secret, auth) + case "tc3": + secret := protocolCredentialField(config, auth.SecretField) + return signProtocolTC3(req, credential, secret, auth) + default: + return fmt.Errorf("插件声明了尚未启用的鉴权驱动 %s", auth.Type) + } +} + +func signProtocolAWSV4(req *http.Request, accessKey, secretKey string, auth protocol.ManifestAuth) error { + if strings.TrimSpace(accessKey) == "" || strings.TrimSpace(secretKey) == "" { + return errors.New("AWS SigV4 鉴权需要 Access Key ID 和 Secret Access Key") + } + serviceName := defaultString(strings.TrimSpace(auth.Service), "bedrock") + region := strings.TrimSpace(auth.Region) + if region == "" { + parts := strings.Split(strings.ToLower(req.URL.Hostname()), ".") + for index, part := range parts { + if strings.HasPrefix(part, serviceName) && index+1 < len(parts) { + region = parts[index+1] + break + } + } + } + if region == "" { + return errors.New("AWS SigV4 鉴权无法从 Base URL 推断 region,请使用包含区域的 Bedrock Runtime 地址") + } + payload, err := protocolRequestPayload(req) + if err != nil { + return err + } + now := time.Now().UTC() + amzDate := now.Format("20060102T150405Z") + dateStamp := now.Format("20060102") + payloadHash := sha256Hex(payload) + req.Header.Set("X-Amz-Date", amzDate) + req.Header.Set("X-Amz-Content-Sha256", payloadHash) + canonicalHeaders, signedHeaders := protocolCanonicalHeaders(req) + canonicalRequest := strings.Join([]string{ + req.Method, + defaultString(req.URL.EscapedPath(), "/"), + req.URL.Query().Encode(), + canonicalHeaders, + signedHeaders, + payloadHash, + }, "\n") + scope := strings.Join([]string{dateStamp, region, serviceName, "aws4_request"}, "/") + stringToSign := strings.Join([]string{"AWS4-HMAC-SHA256", amzDate, scope, sha256Hex([]byte(canonicalRequest))}, "\n") + dateKey := protocolHMAC([]byte("AWS4"+secretKey), dateStamp) + regionKey := protocolHMAC(dateKey, region) + serviceKey := protocolHMAC(regionKey, serviceName) + signingKey := protocolHMAC(serviceKey, "aws4_request") + signature := hex.EncodeToString(protocolHMAC(signingKey, stringToSign)) + req.Header.Set("Authorization", fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", accessKey, scope, signedHeaders, signature)) + return nil +} + +func signProtocolTC3(req *http.Request, secretID, secretKey string, auth protocol.ManifestAuth) error { + if strings.TrimSpace(secretID) == "" || strings.TrimSpace(secretKey) == "" { + return errors.New("腾讯云 TC3 鉴权需要 SecretId 和 SecretKey") + } + serviceName := defaultString(strings.TrimSpace(auth.Service), "hunyuan") + payload, err := protocolRequestPayload(req) + if err != nil { + return err + } + now := time.Now().UTC() + timestamp := now.Unix() + dateStamp := now.Format("2006-01-02") + contentType := defaultString(req.Header.Get("Content-Type"), "application/json") + req.Header.Set("Content-Type", contentType) + req.Header.Set("X-TC-Timestamp", strconv.FormatInt(timestamp, 10)) + if region := strings.TrimSpace(auth.Region); region != "" { + req.Header.Set("X-TC-Region", region) + } + canonicalHeaders := "content-type:" + strings.ToLower(strings.TrimSpace(contentType)) + "\n" + "host:" + strings.ToLower(req.URL.Host) + "\n" + signedHeaders := "content-type;host" + canonicalRequest := strings.Join([]string{req.Method, defaultString(req.URL.EscapedPath(), "/"), req.URL.Query().Encode(), canonicalHeaders, signedHeaders, sha256Hex(payload)}, "\n") + scope := dateStamp + "/" + serviceName + "/tc3_request" + stringToSign := strings.Join([]string{"TC3-HMAC-SHA256", strconv.FormatInt(timestamp, 10), scope, sha256Hex([]byte(canonicalRequest))}, "\n") + secretDate := protocolHMAC([]byte("TC3"+secretKey), dateStamp) + secretService := protocolHMAC(secretDate, serviceName) + secretSigning := protocolHMAC(secretService, "tc3_request") + signature := hex.EncodeToString(protocolHMAC(secretSigning, stringToSign)) + req.Header.Set("Authorization", fmt.Sprintf("TC3-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s", secretID, scope, signedHeaders, signature)) + return nil +} + +func protocolRequestPayload(req *http.Request) ([]byte, error) { + if req.Body == nil { + return nil, nil + } + var reader io.ReadCloser + var err error + if req.GetBody != nil { + reader, err = req.GetBody() + } else { + reader = req.Body + } + if err != nil { + return nil, err + } + data, err := io.ReadAll(reader) + if req.GetBody != nil { + _ = reader.Close() + } else { + req.Body = io.NopCloser(bytes.NewReader(data)) + } + return data, err +} + +func protocolCanonicalHeaders(req *http.Request) (string, string) { + values := map[string]string{"host": strings.ToLower(req.URL.Host)} + for name, entries := range req.Header { + lower := strings.ToLower(strings.TrimSpace(name)) + if lower == "authorization" || lower == "user-agent" || lower == "content-length" || lower == "expect" { + continue + } + cleaned := make([]string, 0, len(entries)) + for _, entry := range entries { + cleaned = append(cleaned, strings.Join(strings.Fields(entry), " ")) + } + values[lower] = strings.Join(cleaned, ",") + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + var canonical strings.Builder + for _, key := range keys { + canonical.WriteString(key) + canonical.WriteByte(':') + canonical.WriteString(values[key]) + canonical.WriteByte('\n') + } + return canonical.String(), strings.Join(keys, ";") +} + +func protocolHMAC(key []byte, value string) []byte { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write([]byte(value)) + return mac.Sum(nil) +} + +func sha256Hex(value []byte) string { + hash := sha256.Sum256(value) + return hex.EncodeToString(hash[:]) +} + +func protocolCredentialField(config providerConfig, field string) string { + switch strings.ToLower(strings.TrimSpace(field)) { + case "secretkey", "secret_key", "secret": + return strings.TrimSpace(config.SecretKey) + default: + return strings.TrimSpace(config.APIKey) + } +} + +func protocolRequestURL(baseURL string, spec protocol.RequestSpec) (string, error) { + if !spec.OriginPath { + return appendProtocolQuery(apiURL(baseURL, spec.Path), spec.Query) + } + base, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || base.Scheme == "" || base.Host == "" { + return "", fmt.Errorf("协议根路径请求的 Base URL 无效") + } + requestPath, err := url.Parse(spec.Path) + if err != nil || !strings.HasPrefix(requestPath.Path, "/") { + return "", fmt.Errorf("协议根路径请求必须使用绝对路径") + } + base.Path = requestPath.Path + base.RawPath = requestPath.RawPath + base.RawQuery = requestPath.RawQuery + base.Fragment = "" + return appendProtocolQuery(base.String(), spec.Query) +} diff --git a/backend/internal/app/provider_protocol_request.go b/backend/internal/app/provider_protocol_request.go new file mode 100644 index 000000000..9e5882d16 --- /dev/null +++ b/backend/internal/app/provider_protocol_request.go @@ -0,0 +1,336 @@ +// 声明式协议插件的请求组装:请求体(JSON/表单/二进制)、媒体引用与 URL。 + +package app + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "sort" + "strconv" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/protocol" +) + +func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRequest { + resolution := strings.TrimSpace(input.Config.VQuality) + if input.Mode == "video" { + if declared := videoResolutionNameRequest(input.VideoCapability, resolution); declared != "" { + resolution = declared + } + } + aspectRatio := input.Config.Size + if input.Mode == "image" && strings.TrimSpace(input.Config.InterfaceType) == string(model.ChannelInterfaceOpenAIImage) { + aspectRatio = normalizePixelSize(aspectRatio) + } + request := protocol.GenerationRequest{ + Capability: protocol.Capability(input.Mode), + Model: input.Config.Model, + Prompt: input.Prompt, + Instructions: strings.TrimSpace(input.Config.SystemPrompt), + Images: protocolImageReferences(input), + Videos: protocolMediaReferences(input.ReferenceVideos, "video"), + Audios: protocolMediaReferences(input.ReferenceAudios, "audio"), + AspectRatio: aspectRatio, + Resolution: resolution, + Quality: input.Config.Quality, + GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false), + Watermark: parseBool(input.Config.VideoWatermark, false), + Operation: firstNonEmpty(metadataString(input.Metadata, "videoEditOperation"), metadataString(input.Metadata, "videoOperation")), + Extra: map[string]any{ + "videoSeconds": input.Config.VideoSeconds, + "audioVoice": input.Config.AudioVoice, + "audioFormat": input.Config.AudioFormat, + "count": input.Config.Count, + }, + } + for _, message := range input.TextHistory { + role := strings.ToLower(strings.TrimSpace(message.Role)) + if role != "user" && role != "assistant" && role != "system" { + continue + } + if content := strings.TrimSpace(message.Content); content != "" { + request.Messages = append(request.Messages, protocol.Message{Role: role, Content: content}) + } + } + request.Inputs = append(request.Inputs, request.Images...) + request.Inputs = append(request.Inputs, request.Videos...) + request.Inputs = append(request.Inputs, request.Audios...) + if input.MaxOutputTokens > 0 { + request.Extra["max_output_tokens"] = input.MaxOutputTokens + request.Extra["max_tokens"] = input.MaxOutputTokens + } + if duration, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds)); err == nil && duration > 0 { + request.Duration = duration + } + if count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count)); err == nil && count > 0 { + request.ImageCount = count + } + request.Output = protocol.OutputOptions{ + Count: request.ImageCount, Duration: request.Duration, AspectRatio: request.AspectRatio, + Resolution: request.Resolution, Quality: request.Quality, GenerateAudio: request.GenerateAudio, + Watermark: request.Watermark, Format: input.Config.AudioFormat, + } + request.ProviderOptions = make(map[string]map[string]any) + if configured, ok := input.Metadata["providerOptions"].(map[string]any); ok { + for namespace, raw := range configured { + if options, ok := raw.(map[string]any); ok { + request.ProviderOptions[strings.TrimSpace(namespace)] = options + } + } + } + return request +} + +func protocolImageReferences(input canvasGenerationInput) []protocol.MediaReference { + if input.Mode == "video" { + return protocolVideoImageReferences(input) + } + result := make([]protocol.MediaReference, 0, len(input.ReferenceImages)+1) + for index, value := range input.ReferenceImages { + item := protocolMediaReference(value, "image", index) + item.Role = "reference_image" + if input.Mode == "image" { + item.Role = "edit_source" + } + if item.URL != "" || item.DataURL != "" { + result = append(result, item) + } + } + if input.Mask != nil { + mask := protocolMediaReference(*input.Mask, "image", len(result)) + mask.Role = "mask" + if mask.URL != "" || mask.DataURL != "" { + result = append(result, mask) + } + } + return result +} + +func protocolVideoImageReferences(input canvasGenerationInput) []protocol.MediaReference { + result := make([]protocol.MediaReference, 0, len(input.ReferenceImages)) + fallbackRole := "" + if metadataString(input.Metadata, "videoStartFrameNodeId") != "" || metadataString(input.Metadata, "videoEndFrameNodeId") != "" { + fallbackRole = "reference_image" + } + for index, value := range input.ReferenceImages { + item := protocolMediaReference(value, "image", index) + item.Role = videoImageRoleOrDefault(input, value, fallbackRole) + if item.URL != "" || item.DataURL != "" { + result = append(result, item) + } + } + return result +} + +func protocolMediaReferences(values []providerMedia, kind string) []protocol.MediaReference { + result := make([]protocol.MediaReference, 0, len(values)) + for index, value := range values { + item := protocolMediaReference(value, kind, index) + if kind == "video" { + item.Role = "reference_video" + } else if kind == "audio" { + item.Role = "reference_audio" + } + if item.URL != "" || item.DataURL != "" { + result = append(result, item) + } + } + return result +} + +func protocolMediaReference(value providerMedia, kind string, order int) protocol.MediaReference { + return protocol.MediaReference{ + ID: strings.TrimSpace(value.ID), URL: strings.TrimSpace(value.URL), DataURL: strings.TrimSpace(value.DataURL), + Kind: kind, MIMEType: firstNonEmpty(strings.TrimSpace(value.MimeType), strings.TrimSpace(value.Type)), Name: strings.TrimSpace(value.Name), Order: order, + Metadata: map[string]any{"bytes": value.Bytes, "width": value.Width, "height": value.Height, "durationMs": value.DurationMs, "storageKey": strings.TrimSpace(value.StorageKey)}, + } +} + +func executeProtocolRequest(ctx context.Context, config providerConfig, spec protocol.RequestSpec) ([]byte, error) { + data, _, err := executeProtocolBinaryRequest(ctx, config, spec) + return data, err +} + +// executeProtocolBinaryRequest 是声明式插件与宿主网络能力之间的边界。manifest 只能声明 +// method/path/body/auth;最终 URL 校验、凭证注入、SSRF、超时、大小限制和审计仍由宿主统一执行, +// 插件不能通过自定义请求规格绕过这些安全约束。 +func executeProtocolBinaryRequest(ctx context.Context, config providerConfig, spec protocol.RequestSpec) ([]byte, string, error) { + return executeProtocolBinaryRequestWithConsumer(ctx, config, spec, nil) +} + +func executeProtocolBinaryRequestWithConsumer(ctx context.Context, config providerConfig, spec protocol.RequestSpec, consume func(string, []byte)) ([]byte, string, error) { + if err := spec.Validate(); err != nil { + return nil, "", err + } + method := strings.ToUpper(strings.TrimSpace(spec.Method)) + body, contentType, err := protocolRequestBody(ctx, config, spec) + if err != nil { + return nil, "", err + } + requestURL, err := protocolRequestURL(config.BaseURL, spec) + if err != nil { + return nil, "", err + } + req, err := http.NewRequestWithContext(ctx, method, requestURL, body) + if err != nil { + return nil, "", err + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + for name, value := range spec.Headers { + req.Header.Set(name, value) + } + ApplyOutboundHeaders(req, config.Headers) + if err := applyProtocolAuth(req, config, spec.Auth); err != nil { + return nil, "", err + } + if consume != nil { + req.Header.Set("Accept", "text/event-stream") + return doBinaryWithConsumer(req, consume) + } + return doBinary(req) +} + +func protocolRequestBody(ctx context.Context, config providerConfig, spec protocol.RequestSpec) (io.Reader, string, error) { + contentType := strings.ToLower(strings.TrimSpace(strings.Split(spec.ContentType, ";")[0])) + if spec.Body == nil && len(spec.Files) == 0 { + return nil, "", nil + } + switch contentType { + case "", "application/json": + data, err := json.Marshal(spec.Body) + if err != nil { + return nil, "", err + } + return bytes.NewReader(data), "application/json", nil + case "application/x-www-form-urlencoded": + values := url.Values{} + for key, value := range protocolBodyObject(spec.Body) { + for _, item := range protocolFormValues(value) { + values.Add(key, item) + } + } + return strings.NewReader(values.Encode()), contentType, nil + case "multipart/form-data": + var body bytes.Buffer + writer := multipart.NewWriter(&body) + keys := make([]string, 0) + for key := range protocolBodyObject(spec.Body) { + keys = append(keys, key) + } + sort.Strings(keys) + for _, key := range keys { + for _, item := range protocolFormValues(protocolBodyObject(spec.Body)[key]) { + if err := writer.WriteField(key, item); err != nil { + _ = writer.Close() + return nil, "", err + } + } + } + for _, file := range spec.Files { + data, detectedMIME, err := protocolMediaBytes(ctx, config, file.Reference) + if err != nil { + _ = writer.Close() + return nil, "", fmt.Errorf("读取 multipart 文件 %s 失败:%w", file.Name, err) + } + filename := safeProtocolFilename(file.Filename) + mimeType := strings.TrimSpace(file.MIMEType) + if mimeType == "" { + mimeType = detectedMIME + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", fmt.Sprintf(`form-data; name=%q; filename=%q`, file.Name, filename)) + header.Set("Content-Type", defaultString(mimeType, "application/octet-stream")) + part, err := writer.CreatePart(header) + if err != nil { + _ = writer.Close() + return nil, "", err + } + if _, err := part.Write(data); err != nil { + _ = writer.Close() + return nil, "", err + } + } + if err := writer.Close(); err != nil { + return nil, "", err + } + return bytes.NewReader(body.Bytes()), writer.FormDataContentType(), nil + case "application/octet-stream": + switch value := spec.Body.(type) { + case []byte: + return bytes.NewReader(value), contentType, nil + case string: + if strings.HasPrefix(value, "data:") { + mimeType, data, err := decodeProviderDataURL(value) + if err != nil { + return nil, "", err + } + return bytes.NewReader(data), defaultString(mimeType, contentType), nil + } + return strings.NewReader(value), contentType, nil + default: + return nil, "", fmt.Errorf("二进制协议请求体必须是字节或字符串") + } + default: + return nil, "", fmt.Errorf("声明式协议暂不支持 %s 请求体", spec.ContentType) + } +} + +func protocolBodyObject(value any) map[string]any { + result, _ := value.(map[string]any) + return result +} + +func protocolFormValues(value any) []string { + switch typed := value.(type) { + case nil: + return nil + case []any: + result := make([]string, 0, len(typed)) + for _, item := range typed { + result = append(result, protocolFormValues(item)...) + } + return result + case string: + return []string{typed} + case bool: + return []string{strconv.FormatBool(typed)} + case float64: + return []string{strconv.FormatFloat(typed, 'f', -1, 64)} + case int: + return []string{strconv.Itoa(typed)} + default: + data, err := json.Marshal(typed) + if err != nil { + return nil + } + return []string{string(data)} + } +} + +func safeProtocolFilename(value string) string { + value = strings.TrimSpace(value) + if index := strings.LastIndexAny(value, `/\\`); index >= 0 { + value = value[index+1:] + } + value = strings.Map(func(r rune) rune { + if r < 32 || r == 127 || r == '"' { + return -1 + } + return r + }, value) + if value == "" { + return "upload.bin" + } + return value +} diff --git a/backend/internal/app/provider_protocol_result.go b/backend/internal/app/provider_protocol_result.go new file mode 100644 index 000000000..c58c8f729 --- /dev/null +++ b/backend/internal/app/provider_protocol_result.go @@ -0,0 +1,218 @@ +// 声明式协议插件的结果处理:输出提取、媒体下载重试与错误归类。 + +package app + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + "infinite-canvas/backend/internal/protocol" +) + +func appendProtocolQuery(rawURL string, values map[string][]string) (string, error) { + if len(values) == 0 { + return rawURL, nil + } + parsed, err := url.Parse(rawURL) + if err != nil { + return "", err + } + query := parsed.Query() + for key, items := range values { + for _, item := range items { + query.Add(key, item) + } + } + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func finishProtocolResult(ctx context.Context, config providerConfig, mode string, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) { + if result == nil { + return nil, errors.New("声明式协议已完成但没有返回结果") + } + if mode == "text" { + output := map[string]interface{}{"mode": "text", "text": result.Text} + if strings.TrimSpace(result.Reasoning) != "" { + output["reasoning"] = result.Reasoning + } + return output, nil + } + var references []protocol.MediaReference + switch mode { + case "image": + references = result.Images + case "video": + references = result.Videos + case "audio": + references = result.Audios + default: + return nil, fmt.Errorf("声明式协议不支持生成模式 %s", mode) + } + if len(references) == 0 { + return nil, errors.New("声明式协议已完成但没有返回媒体地址") + } + if output, handled, err := recoverProtocolMedia(ctx, config, mode, references); handled { + return output, err + } + items := make([]interface{}, 0, len(references)) + for _, reference := range references { + var data []byte + var mimeType string + var err error + if mode == "video" { + data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, func(ctx context.Context) ([]byte, string, error) { + return protocolMediaBytesOnce(ctx, config, reference) + }) + } else { + data, mimeType, err = protocolMediaBytes(ctx, config, reference) + } + if err != nil { + return nil, err + } + item := map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType} + items = append(items, item) + } + switch mode { + case "image": + return map[string]interface{}{"mode": "image", "images": items}, nil + case "video": + return map[string]interface{}{"mode": "video", "video": items[0]}, nil + default: + return map[string]interface{}{"mode": "audio", "audio": items[0]}, nil + } +} + +// finishProtocolAdapterResult 优先消费 create/poll 已返回的内联或 URL 结果,只有插件明确声明独立结果端点时才下载。 +// 空响应或下载失败必须向上失败,不能生成伪素材;application/octet-stream 仅表示传输层未知类型, +// 不会把未知内容伪装成具体图片、视频或音频 MIME。 +func finishProtocolAdapterResult(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter, request protocol.GenerationRequest, taskID string, result *protocol.Result, pollPolicy videoPollPolicy) (map[string]interface{}, error) { + if protocolResultHasOutput(input.Mode, result) { + return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy) + } + resultAdapter, ok := adapter.(protocol.ResultAdapter) + capability, hasCapability := adapter.(protocol.ResultCapability) + if !ok || !hasCapability || !capability.ResultAvailable() { + return finishProtocolResult(ctx, input.Config, input.Mode, taskID, result, pollPolicy) + } + spec, err := resultAdapter.BuildResult(ctx, protocol.PollContext{BaseURL: input.Config.BaseURL, Model: request.Model, Request: request, TaskID: taskID}) + if err != nil { + return nil, err + } + download := func(ctx context.Context) ([]byte, string, error) { + return executeProtocolBinaryRequest(withProviderRequestKind(ctx, "download"), input.Config, spec) + } + var data []byte + var mimeType string + if input.Mode == "video" { + data, mimeType, err = runVideoDownload(ctx, taskID, pollPolicy, download) + } else { + data, mimeType, err = download(ctx) + } + if err != nil { + return nil, fmt.Errorf("声明式协议结果下载失败:%w", err) + } + if len(data) == 0 { + return nil, errors.New("声明式协议结果下载返回空内容") + } + mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) + if mimeType == "" { + mimeType = "application/octet-stream" + } + reference := protocol.MediaReference{DataURL: dataURL(mimeType, data), MIMEType: mimeType} + downloaded := &protocol.Result{} + switch input.Mode { + case "image": + downloaded.Images = []protocol.MediaReference{reference} + case "video": + downloaded.Videos = []protocol.MediaReference{reference} + case "audio": + downloaded.Audios = []protocol.MediaReference{reference} + default: + return nil, fmt.Errorf("声明式协议结果下载不支持生成模式 %s", input.Mode) + } + return finishProtocolResult(ctx, input.Config, input.Mode, taskID, downloaded, pollPolicy) +} + +func protocolResultHasOutput(mode string, result *protocol.Result) bool { + if result == nil { + return false + } + switch mode { + case "text": + return strings.TrimSpace(result.Text) != "" + case "image": + return len(result.Images) > 0 + case "video": + return len(result.Videos) > 0 + case "audio": + return len(result.Audios) > 0 + default: + return false + } +} + +func protocolMediaBytes(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) { + var data []byte + var mimeType string + var err error + for attempt := 0; attempt < 3; attempt++ { + data, mimeType, err = protocolMediaBytesOnce(ctx, config, reference) + if err == nil { + return data, mimeType, nil + } + if attempt == 2 || !retryableProtocolMediaDownload(err) { + break + } + if waitErr := sleepContext(ctx, time.Duration(attempt+1)*time.Second); waitErr != nil { + return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", waitErr) + } + } + return nil, "", fmt.Errorf("声明式协议媒体结果下载失败:%w", err) +} + +func protocolMediaBytesOnce(ctx context.Context, config providerConfig, reference protocol.MediaReference) ([]byte, string, error) { + if strings.TrimSpace(reference.DataURL) != "" { + mimeType, data, err := decodeProviderDataURL(reference.DataURL) + return data, mimeType, err + } + value := strings.TrimSpace(reference.URL) + if value == "" { + return nil, "", errors.New("声明式协议媒体结果地址为空") + } + data, mimeType, err := getProviderExternalBinary(withProviderRequestKind(ctx, "download"), config, value) + return data, normalizedMediaMimeType(mimeType, data), err +} + +func retryableProtocolMediaDownload(err error) bool { + if err == nil || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return false + } + var networkError net.Error + if errors.As(err, &networkError) && (networkError.Timeout() || networkError.Temporary()) { + return true + } + message := strings.ToLower(err.Error()) + for _, marker := range []string{"tls handshake timeout", "connection reset", "unexpected eof", "broken pipe"} { + if strings.Contains(message, marker) { + return true + } + } + return false +} + +func protocolResultError(message, taskID string) error { + message = strings.TrimSpace(message) + if message == "" { + message = "上游返回失败状态" + } + if taskID == "" { + return errors.New(message) + } + return fmt.Errorf("声明式协议任务失败(任务 %s):%s", taskID, message) +} diff --git a/backend/internal/app/provider_style.go b/backend/internal/app/provider_style.go new file mode 100644 index 000000000..fa6927cce --- /dev/null +++ b/backend/internal/app/provider_style.go @@ -0,0 +1,241 @@ +// 生成任务的风格档案(Style Profile)解析与应用:把项目/画布风格合成进提示词与参考素材, +// 并校验风格素材对所选模型可用。 + +package app + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strings" + + "gorm.io/gorm" +) + +type styleExecutionPlanDocument struct { + SchemaVersion int `json:"schemaVersion"` + ProfilePresetID string `json:"profilePresetId"` + ProfileRevision int `json:"profileRevision"` + Mode string `json:"mode"` + Model string `json:"model"` + InterfaceType string `json:"interfaceType"` + Status string `json:"status"` + Prompt string `json:"prompt"` +} + +func (s *Service) applyGenerationStyleProfile(userID string, taskProjectID string, input *canvasGenerationInput) error { + styleProfileJSON := metadataString(input.Metadata, "styleProfileJson") + if strings.TrimSpace(styleProfileJSON) == "" { + return nil + } + if _, err := validateStyleProfileJSON(styleProfileJSON); err != nil { + return fmt.Errorf("项目画风执行配置无效:%w", err) + } + var profile styleProfileDocument + if err := json.Unmarshal([]byte(styleProfileJSON), &profile); err != nil { + return fmt.Errorf("项目画风执行配置解析失败:%w", err) + } + storedProfileJSON, storedPresetID, belongsToProject, err := s.taskProjectStyleProfile(userID, taskProjectID) + if err != nil { + return fmt.Errorf("读取项目画风失败:%w", err) + } + if belongsToProject { + if strings.TrimSpace(storedProfileJSON) == "" { + // 旧项目只有 preset ID,允许画布把该预设编译为结构化快照;仍需锁定同一预设,不能借降级路径换画风。 + if strings.TrimSpace(storedPresetID) == "" || strings.TrimSpace(profile.PresetID) != strings.TrimSpace(storedPresetID) { + return errors.New("项目画风已发生变化,请返回项目列表后重新打开当前项目再生成") + } + } else { + matches, compareErr := equivalentStyleProfileJSON(styleProfileJSON, storedProfileJSON) + if compareErr != nil { + return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试") + } + if !matches { + // 项目画风可能在另一个页面更新;保存路径以服务端快照为准,生成时自动采用最新版本。 + validatedStoredProfileJSON, validateErr := validateStyleProfileJSON(storedProfileJSON) + if validateErr != nil || json.Unmarshal([]byte(validatedStoredProfileJSON), &profile) != nil { + return errors.New("项目画风配置暂时无法读取,请在项目设置中重新保存画风后重试") + } + } + } + } + // 执行计划是前端为即时预览生成的派生数据。平台模型入队后可能改选真实供应线路, + // 因此后端必须以最终模型重新编译,不能要求用户手动“刷新配置”来同步内部路由。 + plan, _ := decodeStyleExecutionPlan(input.Metadata["styleExecutionPlan"]) + stylePrompt, expectedStatus, warnings := resolveGenerationStyleExecution(profile, input.Config.Model, firstNonEmpty(input.Config.InterfaceType, input.Config.APIFormat)) + input.Prompt = reconcileGenerationStylePrompt(input.Prompt, plan.Prompt, stylePrompt) + if expectedStatus == "blocked" { + return fmt.Errorf("当前图片模型无法完整执行项目画风:%s。请切换图片模型,或在项目设置中停用对应画风资产", strings.Join(warnings, ";")) + } + return nil +} + +func reconcileGenerationStylePrompt(prompt string, previousStylePrompt string, currentStylePrompt string) string { + content := strings.TrimSpace(prompt) + previous := strings.TrimSpace(previousStylePrompt) + if previous != "" { + previousBlock := "【项目画风执行规范】\n" + previous + if strings.HasSuffix(content, previousBlock) { + content = strings.TrimSpace(strings.TrimSuffix(content, previousBlock)) + } + } + current := strings.TrimSpace(currentStylePrompt) + if current == "" || strings.HasSuffix(content, "【项目画风执行规范】\n"+current) { + return content + } + return strings.TrimSpace(content + "\n\n【项目画风执行规范】\n" + current) +} + +func (s *Service) taskProjectStyleProfile(userID string, canvasOrProjectID string) (string, string, bool, error) { + id := strings.TrimSpace(canvasOrProjectID) + if id == "" { + return "", "", false, nil + } + if canvas, err := s.repo.CanvasProjectForUser(userID, id); err == nil { + if strings.TrimSpace(canvas.ProjectID) == "" { + return "", "", false, nil + } + project, projectErr := s.repo.ProjectForUser(userID, canvas.ProjectID) + if projectErr != nil { + return "", "", true, projectErr + } + return project.StyleProfileJSON, project.StylePresetID, true, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", false, err + } + project, err := s.repo.ProjectForUser(userID, id) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return "", "", false, nil + } + return "", "", false, err + } + return project.StyleProfileJSON, project.StylePresetID, true, nil +} + +func equivalentStyleProfileJSON(left string, right string) (bool, error) { + var leftValue interface{} + if err := json.Unmarshal([]byte(left), &leftValue); err != nil { + return false, err + } + var rightValue interface{} + if err := json.Unmarshal([]byte(right), &rightValue); err != nil { + return false, err + } + leftCanonical, err := json.Marshal(leftValue) + if err != nil { + return false, err + } + rightCanonical, err := json.Marshal(rightValue) + if err != nil { + return false, err + } + return bytes.Equal(leftCanonical, rightCanonical), nil +} + +func decodeStyleExecutionPlan(value interface{}) (styleExecutionPlanDocument, error) { + if value == nil { + return styleExecutionPlanDocument{}, errors.New("项目画风执行计划缺失") + } + raw, err := json.Marshal(value) + if err != nil { + return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效") + } + var plan styleExecutionPlanDocument + if err := json.Unmarshal(raw, &plan); err != nil { + return styleExecutionPlanDocument{}, errors.New("项目画风执行计划格式无效") + } + return plan, nil +} + +func resolveGenerationStyleExecution(profile styleProfileDocument, generationModel string, interfaceType string) (string, string, []string) { + fragments := []string{strings.TrimSpace(profile.Prompt)} + if negative := strings.TrimSpace(profile.NegativePrompt); negative != "" { + fragments = append(fragments, "【全局负面 Prompt】\n"+negative) + } + warnings := make([]string, 0) + for _, asset := range profile.Assets { + if asset.Enabled != nil && !*asset.Enabled { + continue + } + if asset.Status != "validated" { + reason := "资产尚未验证" + if asset.Status == "unavailable" { + reason = "资产当前不可用" + } + warnings = append(warnings, asset.Title+":"+reason) + continue + } + if len(asset.BaseModels) > 0 && !styleAssetSupportsModel(asset.BaseModels, generationModel) { + warnings = append(warnings, asset.Title+":仅兼容 "+strings.Join(asset.BaseModels, "、")) + continue + } + switch asset.Kind { + case "prompt", "template": + fragments = append(fragments, strings.TrimSpace(asset.PromptFragment)) + fragments = append(fragments, nonEmptyStyleProfileStrings(asset.TriggerWords)...) + case "reference": + warnings = append(warnings, asset.Title+":项目参考图自动注入适配器尚未启用") + case "lora": + warnings = append(warnings, asset.Title+":当前 "+firstNonEmpty(interfaceType, "图片")+" 协议未启用 LoRA 适配器") + } + } + normalizedFragments := nonEmptyStyleProfileStrings(fragments) + status := "ready" + if len(warnings) > 0 { + status = "degraded" + if profile.ExecutionPolicy == "strict-assets" { + status = "blocked" + } + } + return strings.Join(normalizedFragments, "\n"), status, warnings +} + +func styleAssetSupportsModel(baseModels []string, generationModel string) bool { + for _, baseModel := range baseModels { + if strings.EqualFold(strings.TrimSpace(baseModel), strings.TrimSpace(generationModel)) { + return true + } + } + return false +} + +func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) error { + fallback := DefaultImageCapabilityConfig(input.Config.InterfaceType, input.Config.Model) + channelID := strings.TrimSpace(input.Config.ChannelID) + if channelID == "" { + if input.Config.CapabilityConfig != nil && input.Config.CapabilityConfig.Image != nil { + input.ImageCapability = input.Config.CapabilityConfig.Image + } else { + input.ImageCapability = fallback + } + return validateImageTask(input.ImageCapability, *input) + } + item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config)) + if err != nil { + return errors.New("当前系统渠道模型未配置或已停用") + } + profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON) + if err != nil { + return errors.New("当前图片模型能力参数无效") + } + if profile != nil && profile.Image != nil { + input.ImageCapability = profile.Image + } else { + input.ImageCapability = fallback + } + return validateImageTask(input.ImageCapability, *input) +} + +func metadataStringValues(value any) map[string]string { + values := map[string]string{} + raw, ok := value.(map[string]interface{}) + if !ok { + return values + } + for key, item := range raw { + values[key] = strings.TrimSpace(fmt.Sprint(item)) + } + return values +} diff --git a/backend/internal/app/provider_text.go b/backend/internal/app/provider_text.go index 8b53ea804..b61c74b52 100644 --- a/backend/internal/app/provider_text.go +++ b/backend/internal/app/provider_text.go @@ -3,795 +3,16 @@ package app // 文本生成、Agent 工具循环和文本流解析。 import ( - "bufio" - "bytes" "context" - "encoding/json" "errors" "fmt" - "log" - "math" "net/http" - "sort" "strings" "infinite-canvas/backend/internal/model" "infinite-canvas/backend/internal/protocol" ) -func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { - // 浏览器持久化的是协议中立请求;资源水合和模型路由完成后才展开上游协议, - // 防止供应商请求体反向污染任务记录,也避免切换模型时复用错误协议。 - if input.AgentRequests != nil && input.AgentRequests.Canonical != nil { - _, declarative := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType) - requests, err := expandCanonicalAgentRequest(input.AgentRequests.Canonical, input.Config, declarative) - if err != nil { - return nil, err - } - input.AgentRequests = requests - } - if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok { - return runDeclarativeAgentTask(ctx, input, adapter) - } - if input.AgentRequests == nil { - return nil, errors.New("画布 Agent 工具请求缺少协议参数") - } - request := input.AgentRequests.ChatCompletion - path := "/chat/completions" - protocol := "chat-completion" - if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse) { - request = input.AgentRequests.Responses - path = "/responses" - protocol = "responses" - } else if input.Config.InterfaceType == string(model.ChannelInterfaceClaudeAPI) { - if input.AgentRequests.Claude != nil { - request = input.AgentRequests.Claude - } - path = "/messages" - protocol = "claude-api" - } - if request == nil { - return nil, errors.New("画布 Agent 工具请求缺少协议参数") - } - body := cloneStringAnyMap(request) - if protocol == "claude-api" && input.AgentRequests.Claude == nil { - body = claudeAgentBody(body) - } - body["model"] = input.Config.Model - applyTextThinking(body, input, protocol) - applyAgentOutputLimit(body, agentStepOutputLimit(input), protocol) - normalizeAgentToolChoice(body, input, protocol) - result, err := postAgentRequest(ctx, input, path, body, protocol) - if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) { - if !isAutoAgentToolChoice(body["tool_choice"]) { - autoBody := cloneStringAnyMap(body) - autoBody["tool_choice"] = "auto" - result, err = postAgentRequest(ctx, input, path, autoBody, protocol) - } - if isAgentToolChoiceCompatibilityError(err) { - withoutToolChoice := cloneStringAnyMap(body) - delete(withoutToolChoice, "tool_choice") - result, err = postAgentRequest(ctx, input, path, withoutToolChoice, protocol) - } - } - if err != nil { - return nil, err - } - return result, nil -} - -func postAgentRequest(ctx context.Context, input canvasGenerationInput, path string, body map[string]interface{}, protocol string) (map[string]interface{}, error) { - if input.StreamText { - return postStreamingAgent(ctx, input.Config, path, body, protocol, input.OnTextDelta, input.OnReasoningDelta) - } - delete(body, "stream") - var payload map[string]interface{} - if err := postJSON(ctx, input.Config, path, body, &payload); err != nil { - return nil, err - } - return parseAgentToolPayload(payload, protocol) -} - -// agentStepOutputLimit 取本次 Agent 调用的输出上限。 -// -// 两个来源语义相同但通道不同:textOptions.maxOutputTokens 是任务信封里下发的策略值 -// (画布 Agent 每步按运行时策略给,persist 在任务输入里,重启后仍在);input.MaxOutputTokens -// 是进程内直传的旧入口(渠道模型能力声明)。都为 0 时不写上限字段,交给上游按剩余上下文放行。 -// 都非零时取较小值:策略上限不该超过模型自己声明的物理上限。 -func agentStepOutputLimit(input canvasGenerationInput) int { - limits := []int{input.TextOptions.MaxOutputTokens, input.MaxOutputTokens} - limit := 0 - for _, candidate := range limits { - if candidate <= 0 { - continue - } - if limit == 0 || candidate < limit { - limit = candidate - } - } - return limit -} - -// applyAgentOutputLimit 按协议写入输出上限字段名:Claude 与 Chat Completions 用 max_tokens, -// Responses 用 max_output_tokens。 -func applyAgentOutputLimit(body map[string]interface{}, limit int, protocol string) { - if limit <= 0 { - return - } - field := "max_tokens" - if protocol == "responses" { - field = "max_output_tokens" - } - applyTextOutputLimit(body, limit, field) -} - -func runDeclarativeAgentTask(ctx context.Context, input canvasGenerationInput, adapter protocol.AgentAdapter) (map[string]interface{}, error) { - wire := input.Config.InterfaceType - if wire == string(model.ChannelInterfaceOpenAIResponse) { - wire = "responses" - } - knownWire := wire == "chat-completion" || wire == "responses" || wire == "claude-api" - if input.TextOptions.Thinking && !knownWire { - return nil, errors.New("当前声明式 Agent 渠道尚不支持思考模式,请关闭思考模式或切换内置协议渠道") - } - if input.AgentRequests == nil { - return nil, errors.New("画布 Agent 工具请求缺少协议参数") - } - request := map[string]any{ - "chatCompletion": input.AgentRequests.ChatCompletion, - "responses": input.AgentRequests.Responses, - "claude": input.AgentRequests.Claude, - "gemini": input.AgentRequests.Gemini, - } - spec, err := adapter.BuildAgent(ctx, protocol.AgentRequestContext{BaseURL: input.Config.BaseURL, Model: input.Config.Model, Request: request}) - if err != nil { - return nil, err - } - if knownWire { - body := protocolBodyObject(spec.Body) - if body == nil { - return nil, errors.New("声明式 Agent 请求体必须是 JSON 对象") - } - applyTextThinking(body, input, wire) - applyAgentOutputLimit(body, agentStepOutputLimit(input), wire) - normalizeAgentToolChoice(body, input, wire) - spec.Body = body - if input.StreamText { - body["stream"] = true - if wire == "chat-completion" { - if err := ensureChatCompletionStreamUsage(body); err != nil { - return nil, err - } - } - parser := newStreamingAgentParser(wire, input.OnTextDelta) - parser.emitReasoning = input.OnReasoningDelta - data, mime, err := executeProtocolBinaryRequestWithConsumer(ctx, input.Config, spec, parser.consume) - if err != nil { - return nil, err - } - if strings.Contains(strings.ToLower(mime), "event-stream") { - parser.flush() - return parser.result() - } - var payload map[string]interface{} - if err := json.Unmarshal(data, &payload); err != nil { - return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err) - } - return parseAgentToolPayload(payload, wire) - } - } - body, err := executeDeclarativeAgentWithGeminiCache(ctx, input, spec) - if err != nil { - return nil, err - } - parsed, err := adapter.ParseAgent(ctx, body) - if err != nil { - return nil, err - } - result := map[string]interface{}{"mode": "text", "text": parsed.Text, "toolCalls": []interface{}{}} - if parsed.Reasoning != "" { - result["reasoning"] = parsed.Reasoning - } - calls := make([]interface{}, 0, len(parsed.ToolCalls)) - for _, call := range parsed.ToolCalls { - mapped := map[string]interface{}{ - "id": call.ID, - "type": "function", - "function": map[string]interface{}{"name": call.Name, "arguments": call.Arguments}, - } - if call.ThoughtSignature != "" { - mapped["thoughtSignature"] = call.ThoughtSignature - } - calls = append(calls, mapped) - } - result["toolCalls"] = calls - if strings.TrimSpace(parsed.Text) == "" && len(calls) == 0 { - return nil, errors.New("声明式 Agent 接口没有返回内容") - } - return result, nil -} - -// executeDeclarativeAgentWithGeminiCache keeps explicit Prompt Cache entirely -// optional: cache creation, cleanup, and a single stale-cache rebuild can never -// turn a valid uncached Agent request into a failed run. -func executeDeclarativeAgentWithGeminiCache(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) ([]byte, error) { - baseSpec, err := cloneProtocolRequestSpec(spec) - if err != nil { - return nil, err - } - executeSpec := baseSpec - cacheUsed := false - if input.Config.InterfaceType == officialGeminiAgentInterface { - prepared, used, prepareErr := prepareOfficialGeminiAgentCache(ctx, input, baseSpec) - if prepareErr != nil { - log.Printf("gemini agent prompt cache unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr)) - } else { - executeSpec, cacheUsed = prepared, used - } - } - body, err := executeProtocolRequest(ctx, input.Config, executeSpec) - if err == nil || input.Config.InterfaceType != officialGeminiAgentInterface || !cacheUsed || !geminiRequestUsesCachedContent(executeSpec) || !isGeminiCachedContentNotFound(err, stringValue(protocolBodyObject(executeSpec.Body)["cachedContent"])) { - return body, err - } - - resourceName := "" - if body := protocolBodyObject(executeSpec.Body); body != nil { - resourceName, _ = body["cachedContent"].(string) - } - if invalidateErr := invalidateOfficialGeminiAgentCache(ctx, input, baseSpec, resourceName); invalidateErr != nil { - // The local identity is still removed whenever possible. Do not replace a - // provider 404 with a cache bookkeeping error or skip the one rebuild. - log.Printf("gemini agent prompt cache invalidation failed: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(invalidateErr)) - } - rebuilt, _, prepareErr := prepareOfficialGeminiAgentCacheMode(ctx, input, baseSpec, true) - if prepareErr != nil { - log.Printf("gemini agent prompt cache rebuild unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr)) - rebuilt = baseSpec - } - // The rebuilt request is intentionally executed only once. If it receives - // another CachedContent 404, the first stale-cache recovery already happened; - // preserve the provider error instead of recursively rebuilding forever. - return executeProtocolRequest(ctx, input.Config, rebuilt) -} - -func claudeAgentBody(request map[string]interface{}) map[string]interface{} { - body := map[string]interface{}{"max_tokens": 4096} - if messages, ok := request["messages"].([]interface{}); ok { - claudeMessages := make([]interface{}, 0, len(messages)) - var system []string - for _, value := range messages { - message, _ := value.(map[string]interface{}) - role := strings.ToLower(strings.TrimSpace(stringField(message, "role"))) - if role == "system" { - if content := strings.TrimSpace(fmt.Sprint(message["content"])); content != "" { - system = append(system, content) - } - continue - } - if role == "tool" { - claudeMessages = append(claudeMessages, map[string]interface{}{"role": "user", "content": []interface{}{map[string]interface{}{ - "type": "tool_result", "tool_use_id": stringField(message, "tool_call_id"), "content": fmt.Sprint(message["content"]), - }}}) - continue - } - role = mapClaudeMessageRole(role) - content := message["content"] - if content == nil { - content = "" - } - if toolCalls, ok := message["tool_calls"].([]interface{}); ok && len(toolCalls) > 0 { - blocks := make([]interface{}, 0, len(toolCalls)) - for _, value := range toolCalls { - toolCall, _ := value.(map[string]interface{}) - function, _ := toolCall["function"].(map[string]interface{}) - blocks = append(blocks, map[string]interface{}{ - "type": "tool_use", "id": stringField(toolCall, "id"), "name": stringField(function, "name"), "input": claudeToolInput(function["arguments"]), - }) - } - content = blocks - } - claudeMessages = append(claudeMessages, map[string]interface{}{"role": role, "content": content}) - } - body["messages"] = claudeMessages - if len(system) > 0 { - body["system"] = []interface{}{map[string]interface{}{ - "type": "text", "text": strings.Join(system, "\n\n"), - "cache_control": map[string]interface{}{"type": "ephemeral"}, - }} - } - } - if tools, ok := request["tools"].([]interface{}); ok && len(tools) > 0 { - claudeTools := make([]interface{}, 0, len(tools)) - for _, value := range tools { - tool, _ := value.(map[string]interface{}) - function, _ := tool["function"].(map[string]interface{}) - if len(function) == 0 { - continue - } - claudeTools = append(claudeTools, map[string]interface{}{ - "name": stringField(function, "name"), "description": stringField(function, "description"), "input_schema": function["parameters"], - }) - } - if len(claudeTools) > 0 { - if last, ok := claudeTools[len(claudeTools)-1].(map[string]interface{}); ok { - last["cache_control"] = map[string]interface{}{"type": "ephemeral"} - } - body["tools"] = claudeTools - } - } - if choice, ok := request["tool_choice"]; ok { - body["tool_choice"] = claudeToolChoice(choice) - } - return body -} - -func mapClaudeMessageRole(role string) string { - if role == "assistant" { - return "assistant" - } - return "user" -} - -func claudeToolInput(value interface{}) interface{} { - if raw, ok := value.(string); ok { - var parsed interface{} - if json.Unmarshal([]byte(raw), &parsed) == nil && parsed != nil { - return parsed - } - } - if value != nil { - return value - } - return map[string]interface{}{} -} - -func claudeToolChoice(value interface{}) interface{} { - switch choice := value.(type) { - case string: - switch strings.ToLower(strings.TrimSpace(choice)) { - case "required": - return map[string]interface{}{"type": "any"} - case "none": - return map[string]interface{}{"type": "auto"} - default: - return map[string]interface{}{"type": "auto"} - } - case map[string]interface{}: - if function, ok := choice["function"].(map[string]interface{}); ok && stringField(function, "name") != "" { - return map[string]interface{}{"type": "tool", "name": stringField(function, "name")} - } - if name := stringField(choice, "name"); name != "" { - return map[string]interface{}{"type": "tool", "name": name} - } - } - return map[string]interface{}{"type": "auto"} -} - -func isAgentToolChoiceCompatibilityError(err error) bool { - if err == nil { - return false - } - message := strings.ToLower(err.Error()) - var payloadErr providerPayloadError - if errors.As(err, &payloadErr) { - message += " " + strings.ToLower(payloadErr.raw) - } - var httpErr providerHTTPError - if errors.As(err, &httpErr) { - message += " " + strings.ToLower(httpErr.Body) - } - return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode") -} - -func isAutoAgentToolChoice(value interface{}) bool { - choice, ok := value.(string) - return ok && strings.EqualFold(strings.TrimSpace(choice), "auto") -} - -func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) { - if err := validateTextPayload(payload); err != nil { - return nil, err - } - result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}} - if protocol == "responses" { - result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload)) - if reasoning := extractResponseReasoning(payload); reasoning != "" { - result["reasoning"] = reasoning - } - calls := make([]interface{}, 0) - for _, value := range interfaceSlice(payload["output"]) { - item, _ := value.(map[string]interface{}) - if stringField(item, "type") != "function_call" { - continue - } - calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}}) - } - result["toolCalls"] = calls - return result, nil - } - if protocol == "claude-api" { - content := interfaceSlice(payload["content"]) - calls := make([]interface{}, 0) - for _, value := range content { - item, _ := value.(map[string]interface{}) - switch stringField(item, "type") { - case "text": - result["text"] = result["text"].(string) + stringField(item, "text") - case "thinking": - result["reasoning"] = resultString(result, "reasoning") + firstNonEmptyString(stringField(item, "thinking"), stringField(item, "text")) - case "tool_use": - arguments, err := json.Marshal(item["input"]) - if err != nil { - return nil, err - } - calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": string(arguments)}}) - } - } - result["toolCalls"] = calls - if result["text"] == "" && len(calls) == 0 { - return nil, errors.New("Claude Agent 接口没有返回内容") - } - return result, nil - } - choices := interfaceSlice(payload["choices"]) - if len(choices) == 0 { - return nil, errors.New("画布 Agent 接口没有返回 choices") - } - choice, _ := choices[0].(map[string]interface{}) - message, _ := choice["message"].(map[string]interface{}) - result["text"] = stringField(message, "content") - if reasoning := firstNonEmptyString(stringField(message, "reasoning_content"), stringField(message, "reasoning")); reasoning != "" { - result["reasoning"] = reasoning - } - calls := make([]interface{}, 0) - for _, value := range interfaceSlice(message["tool_calls"]) { - item, _ := value.(map[string]interface{}) - function, _ := item["function"].(map[string]interface{}) - calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(function, "name"), "arguments": stringField(function, "arguments")}}) - } - result["toolCalls"] = calls - return result, nil -} - -func postStreamingAgent(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string), onReasoning ...func(string)) (map[string]interface{}, error) { - body["stream"] = true - if protocol == "chat-completion" { - if err := ensureChatCompletionStreamUsage(body); err != nil { - return nil, err - } - } - parser := newStreamingAgentParser(protocol, onDelta) - if len(onReasoning) > 0 { - parser.emitReasoning = onReasoning[0] - } - data, mimeType, err := postStreamingBinary(ctx, config, path, body, parser.consume) - if err != nil { - return nil, err - } - if !strings.Contains(strings.ToLower(mimeType), "event-stream") { - var payload map[string]interface{} - if err := json.Unmarshal(data, &payload); err != nil { - return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err) - } - return parseAgentToolPayload(payload, protocol) - } - parser.flush() - return parser.result() -} - -type streamingAgentToolCall struct { - id string - name string - arguments string -} - -type streamingAgentParser struct { - protocol string - buffer string - text strings.Builder - reasoning strings.Builder - toolCalls map[int]*streamingAgentToolCall - toolCallByID map[string]int - completed map[string]interface{} - err error - emit func(string) - emitReasoning func(string) -} - -func newStreamingAgentParser(protocol string, emit func(string)) *streamingAgentParser { - return &streamingAgentParser{protocol: protocol, toolCalls: map[int]*streamingAgentToolCall{}, toolCallByID: map[string]int{}, emit: emit} -} - -func (p *streamingAgentParser) consume(mimeType string, chunk []byte) { - if p == nil || p.err != nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 { - return - } - p.buffer += string(chunk) - p.consumeFrames(false) -} - -func (p *streamingAgentParser) flush() { - if p == nil || p.err != nil { - return - } - p.consumeFrames(true) -} - -func (p *streamingAgentParser) consumeFrames(flush bool) { - for p.err == nil { - match := sseFrameBoundaryPattern.FindStringIndex(p.buffer) - if match == nil { - break - } - p.consumeFrame(p.buffer[:match[0]]) - p.buffer = p.buffer[match[1]:] - } - if flush && p.err == nil && strings.TrimSpace(p.buffer) != "" { - p.consumeFrame(p.buffer) - p.buffer = "" - } -} - -func (p *streamingAgentParser) consumeFrame(frame string) { - var eventName string - var dataLines []string - for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") { - switch { - case strings.HasPrefix(line, "event:"): - eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - case strings.HasPrefix(line, "data:"): - dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) - } - } - raw := strings.TrimSpace(strings.Join(dataLines, "\n")) - if raw == "" || raw == "[DONE]" { - return - } - var payload map[string]interface{} - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - p.err = fmt.Errorf("Agent 流式事件解析失败:%w", err) - return - } - if err := validateTextPayload(payload); err != nil { - p.err = err - return - } - switch p.protocol { - case "responses": - p.consumeResponsesEvent(eventName, payload) - case "claude-api": - p.consumeClaudeEvent(payload) - default: - p.consumeChatCompletionEvent(payload) - } -} - -func (p *streamingAgentParser) consumeResponsesEvent(eventName string, payload map[string]interface{}) { - eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type")) - switch eventType { - case "response.output_text.delta", "output_text.delta": - p.appendText(stringField(payload, "delta")) - case "response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta": - p.appendReasoning(stringField(payload, "delta")) - case "response.completed": - p.completed, _ = payload["response"].(map[string]interface{}) - case "response.output_item.added": - item, _ := payload["item"].(map[string]interface{}) - if stringField(item, "type") == "function_call" { - index := intField(payload, "output_index", len(p.toolCalls)) - p.setToolCall(index, firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), stringField(item, "name"), stringField(item, "arguments")) - } - case "response.function_call_arguments.delta": - index := p.responseToolCallIndex(payload) - p.toolCall(index).arguments += stringField(payload, "delta") - case "response.function_call_arguments.done": - index := p.responseToolCallIndex(payload) - if arguments := stringField(payload, "arguments"); arguments != "" { - p.toolCall(index).arguments = arguments - } - } -} - -func (p *streamingAgentParser) responseToolCallIndex(payload map[string]interface{}) int { - itemID := firstNonEmptyString(stringField(payload, "item_id"), stringField(payload, "call_id")) - if index, ok := p.toolCallByID[itemID]; ok { - return index - } - return intField(payload, "output_index", len(p.toolCalls)) -} - -func (p *streamingAgentParser) consumeChatCompletionEvent(payload map[string]interface{}) { - choices, _ := payload["choices"].([]interface{}) - for _, value := range choices { - choice, _ := value.(map[string]interface{}) - delta, _ := choice["delta"].(map[string]interface{}) - p.appendText(streamContentText(delta["content"])) - p.appendReasoning(firstRawString(stringField(delta, "reasoning_content"), stringField(delta, "reasoning"), stringField(delta, "reasoning_text"))) - for fallbackIndex, toolValue := range interfaceSlice(delta["tool_calls"]) { - tool, _ := toolValue.(map[string]interface{}) - index := intField(tool, "index", fallbackIndex) - function, _ := tool["function"].(map[string]interface{}) - current := p.toolCall(index) - if id := stringField(tool, "id"); id != "" { - current.id = id - p.toolCallByID[id] = index - } - if name := stringField(function, "name"); name != "" { - current.name += name - } - current.arguments += stringField(function, "arguments") - } - } -} - -func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{}) { - switch stringField(payload, "type") { - case "content_block_start": - block, _ := payload["content_block"].(map[string]interface{}) - index := intField(payload, "index", len(p.toolCalls)) - switch stringField(block, "type") { - case "text": - p.appendText(stringField(block, "text")) - case "thinking": - p.appendReasoning(firstRawString(stringField(block, "thinking"), stringField(block, "text"))) - case "tool_use": - arguments := "" - if input := block["input"]; input != nil { - if encoded, err := json.Marshal(input); err == nil && string(encoded) != "{}" { - arguments = string(encoded) - } - } - p.setToolCall(index, stringField(block, "id"), stringField(block, "name"), arguments) - } - case "content_block_delta": - delta, _ := payload["delta"].(map[string]interface{}) - index := intField(payload, "index", len(p.toolCalls)-1) - if stringField(delta, "type") == "text_delta" { - p.appendText(stringField(delta, "text")) - } - if stringField(delta, "type") == "thinking_delta" { - p.appendReasoning(firstRawString(stringField(delta, "thinking"), stringField(delta, "text"))) - } - if stringField(delta, "type") == "input_json_delta" { - p.toolCall(index).arguments += stringField(delta, "partial_json") - } - case "error": - errValue, _ := payload["error"].(map[string]interface{}) - p.err = errors.New(defaultString(stringField(errValue, "message"), "Claude 上游返回失败")) - } -} - -func resultString(value map[string]interface{}, key string) string { - return stringField(value, key) -} - -func (p *streamingAgentParser) appendText(delta string) { - if delta == "" { - return - } - p.text.WriteString(delta) - if p.emit != nil { - p.emit(delta) - } -} - -func (p *streamingAgentParser) appendReasoning(delta string) { - if delta == "" { - return - } - p.reasoning.WriteString(delta) - if p.emitReasoning != nil { - p.emitReasoning(delta) - } -} - -func (p *streamingAgentParser) toolCall(index int) *streamingAgentToolCall { - if index < 0 { - index = 0 - } - if p.toolCalls[index] == nil { - p.toolCalls[index] = &streamingAgentToolCall{} - } - return p.toolCalls[index] -} - -func (p *streamingAgentParser) setToolCall(index int, id string, name string, arguments string) { - call := p.toolCall(index) - call.id, call.name, call.arguments = id, name, arguments - if id != "" { - p.toolCallByID[id] = index - } -} - -func (p *streamingAgentParser) result() (map[string]interface{}, error) { - if p.err != nil { - return nil, p.err - } - if p.completed != nil { - result, err := parseAgentToolPayload(p.completed, p.protocol) - if err != nil { - return nil, err - } - if p.text.Len() > 0 { - result["text"] = p.text.String() - } - if p.reasoning.Len() > 0 { - result["reasoning"] = p.reasoning.String() - } - return result, nil - } - result := map[string]interface{}{"mode": "text", "text": p.text.String(), "toolCalls": []interface{}{}} - if p.reasoning.Len() > 0 { - result["reasoning"] = p.reasoning.String() - } - indices := make([]int, 0, len(p.toolCalls)) - for index := range p.toolCalls { - indices = append(indices, index) - } - sort.Ints(indices) - calls := make([]interface{}, 0, len(indices)) - for _, index := range indices { - call := p.toolCalls[index] - if call == nil || strings.TrimSpace(call.id) == "" || strings.TrimSpace(call.name) == "" { - continue - } - arguments := call.arguments - if strings.TrimSpace(arguments) == "" { - arguments = "{}" - } - var parsed interface{} - if err := json.Unmarshal([]byte(arguments), &parsed); err != nil { - return nil, fmt.Errorf("Agent 工具参数不是完整 JSON:%w", err) - } - calls = append(calls, map[string]interface{}{"id": call.id, "type": "function", "function": map[string]interface{}{"name": call.name, "arguments": arguments}}) - } - result["toolCalls"] = calls - if p.text.Len() == 0 && len(calls) == 0 { - return nil, errors.New("画布 Agent 接口没有返回内容") - } - return result, nil -} - -func intField(value map[string]interface{}, key string, fallback int) int { - number, ok := value[key].(float64) - if !ok || math.IsNaN(number) || math.IsInf(number, 0) { - return fallback - } - return int(number) -} - -func extractResponseReasoning(payload map[string]interface{}) string { - var chunks []string - for _, value := range interfaceSlice(payload["output"]) { - item, _ := value.(map[string]interface{}) - if stringField(item, "type") != "reasoning" { - continue - } - for _, key := range []string{"summary", "content"} { - for _, part := range interfaceSlice(item[key]) { - record, _ := part.(map[string]interface{}) - if text := strings.TrimSpace(stringField(record, "text")); text != "" { - chunks = append(chunks, text) - } - } - } - } - return strings.Join(chunks, "\n") -} - -func interfaceSlice(value interface{}) []interface{} { - items, _ := value.([]interface{}) - return items -} - -func cloneStringAnyMap(value map[string]interface{}) map[string]interface{} { - cloned := make(map[string]interface{}, len(value)+1) - for key, item := range value { - cloned[key] = item - } - return cloned -} - func runTextTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { if _, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok { return runDeclarativeProtocolTask(ctx, input) @@ -1101,317 +322,3 @@ func shouldFallbackTextToChat(err error) bool { return false } } - -func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool, onDelta func(string)) (providerTextResult, error) { - if stream { - return postStreamingTextResult(ctx, config, path, body, protocol, onDelta) - } - var payload map[string]interface{} - if err := postJSON(ctx, config, path, body, &payload); err != nil { - return providerTextResult{}, err - } - parsed, err := parseAgentToolPayload(payload, protocol) - if err != nil { - return providerTextResult{}, err - } - result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")} - if result.Text == "" { - return providerTextResult{}, errors.New("文本接口没有返回内容") - } - return result, nil -} - -func postStreamingText(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (string, error) { - result, err := postStreamingTextResult(ctx, config, path, body, protocol, onDelta) - return result.Text, err -} - -func postStreamingTextResult(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (providerTextResult, error) { - // 文本创作与 Agent 共用同一套 SSE 解析,确保正文、推理摘要和供应商错误语义一致。 - parsed, err := postStreamingAgent(ctx, config, path, body, protocol, onDelta) - if err != nil { - return providerTextResult{}, err - } - result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")} - if result.Text == "" { - return providerTextResult{}, errors.New("流式文本接口没有返回内容") - } - return result, nil -} - -func extractTextPayload(payload map[string]interface{}, protocol string) string { - if protocol == "claude-api" { - content, _ := payload["content"].([]interface{}) - var result strings.Builder - for _, item := range content { - record, _ := item.(map[string]interface{}) - if stringField(record, "type") == "text" { - result.WriteString(stringField(record, "text")) - } - } - return result.String() - } - if protocol == "responses" { - text := stringField(payload, "output_text") - if text == "" { - text = extractResponseText(payload) - } - return text - } - return extractChatCompletionText(payload) -} - -func validateTextPayload(payload map[string]interface{}) error { - if code, ok := payload["code"].(float64); ok && code != 0 { - rawMessage := defaultString(stringField(payload, "msg"), "请求失败") - return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)} - } - if errValue, ok := payload["error"].(map[string]interface{}); ok { - if message := stringField(errValue, "message"); message != "" { - return providerPayloadError{raw: message, message: providerPayloadErrorMessage(message)} - } - } - return nil -} - -func parseTextEventStream(data []byte, protocol string) (string, error) { - scanner := bufio.NewScanner(bytes.NewReader(data)) - scanner.Buffer(make([]byte, 64<<10), len(data)+1) - var text strings.Builder - var eventName string - var dataLines []string - - flush := func() error { - if len(dataLines) == 0 { - eventName = "" - return nil - } - raw := strings.TrimSpace(strings.Join(dataLines, "\n")) - dataLines = nil - if raw == "" || raw == "[DONE]" { - eventName = "" - return nil - } - var payload map[string]interface{} - if err := json.Unmarshal([]byte(raw), &payload); err != nil { - return fmt.Errorf("流式文本事件解析失败:%w", err) - } - if eventName == "error" { - if err := validateTextPayload(payload); err != nil { - return err - } - return errors.New("上游流式文本请求失败") - } - if err := validateTextPayload(payload); err != nil { - return err - } - if protocol == "responses" { - text.WriteString(stringField(payload, "delta")) - } else if protocol == "claude-api" { - delta, _ := payload["delta"].(map[string]interface{}) - if stringField(delta, "type") == "text_delta" { - text.WriteString(stringField(delta, "text")) - } - } else { - choices, _ := payload["choices"].([]interface{}) - for _, choice := range choices { - record, _ := choice.(map[string]interface{}) - delta, _ := record["delta"].(map[string]interface{}) - text.WriteString(streamContentText(delta["content"])) - } - } - eventName = "" - return nil - } - - for scanner.Scan() { - line := strings.TrimSuffix(scanner.Text(), "\r") - if line == "" { - if err := flush(); err != nil { - return "", err - } - continue - } - switch { - case strings.HasPrefix(line, "event:"): - eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - case strings.HasPrefix(line, "data:"): - value := strings.TrimPrefix(line, "data:") - dataLines = append(dataLines, strings.TrimPrefix(value, " ")) - } - } - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("读取流式文本响应失败:%w", err) - } - if err := flush(); err != nil { - return "", err - } - if text.Len() == 0 { - return "", errors.New("流式文本接口没有返回内容") - } - return text.String(), nil -} - -func streamContentText(value interface{}) string { - if text, ok := value.(string); ok { - return text - } - parts, ok := value.([]interface{}) - if !ok { - return "" - } - var result strings.Builder - for _, part := range parts { - record, _ := part.(map[string]interface{}) - result.WriteString(stringField(record, "text")) - } - return result.String() -} - -type streamingTextDeltaParser struct { - protocol string - buffer string - emit func(string) -} - -func newStreamingTextDeltaParser(protocol string, emit func(string)) *streamingTextDeltaParser { - return &streamingTextDeltaParser{protocol: protocol, emit: emit} -} - -func (p *streamingTextDeltaParser) consume(mimeType string, chunk []byte) { - if p == nil || p.emit == nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 { - return - } - p.buffer += string(chunk) - p.consumeFrames(false) -} - -func (p *streamingTextDeltaParser) flush() { - if p == nil || p.emit == nil { - return - } - p.consumeFrames(true) -} - -func (p *streamingTextDeltaParser) consumeFrames(flush bool) { - for { - match := sseFrameBoundaryPattern.FindStringIndex(p.buffer) - if match == nil { - break - } - p.consumeFrame(p.buffer[:match[0]]) - p.buffer = p.buffer[match[1]:] - } - if flush && strings.TrimSpace(p.buffer) != "" { - p.consumeFrame(p.buffer) - p.buffer = "" - } -} - -func (p *streamingTextDeltaParser) consumeFrame(frame string) { - var eventName string - var dataLines []string - for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") { - switch { - case strings.HasPrefix(line, "event:"): - eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) - case strings.HasPrefix(line, "data:"): - dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) - } - } - raw := strings.TrimSpace(strings.Join(dataLines, "\n")) - if raw == "" || raw == "[DONE]" { - return - } - var payload map[string]interface{} - if json.Unmarshal([]byte(raw), &payload) != nil { - return - } - if delta := streamingTextDelta(p.protocol, eventName, payload); delta != "" { - p.emit(delta) - } -} - -func streamingTextDelta(protocol string, eventName string, payload map[string]interface{}) string { - if protocol == "responses" { - eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type")) - if eventType == "response.output_text.delta" || eventType == "output_text.delta" || eventType == "" || eventType == "message" { - return stringField(payload, "delta") - } - return "" - } - if protocol == "claude-api" { - delta, _ := payload["delta"].(map[string]interface{}) - if stringField(delta, "type") == "text_delta" { - return stringField(delta, "text") - } - return "" - } - choices, _ := payload["choices"].([]interface{}) - var text strings.Builder - for _, choice := range choices { - record, _ := choice.(map[string]interface{}) - delta, _ := record["delta"].(map[string]interface{}) - text.WriteString(streamContentText(delta["content"])) - } - return text.String() -} - -func extractResponseText(payload map[string]interface{}) string { - output, ok := payload["output"].([]interface{}) - if !ok { - return "" - } - var chunks []string - for _, item := range output { - record, ok := item.(map[string]interface{}) - if !ok || record["type"] != "message" { - continue - } - content, _ := record["content"].([]interface{}) - for _, part := range content { - partRecord, ok := part.(map[string]interface{}) - if ok && stringField(partRecord, "text") != "" { - chunks = append(chunks, stringField(partRecord, "text")) - } - } - } - return strings.Join(chunks, "") -} - -func extractChatCompletionText(payload map[string]interface{}) string { - if data, ok := payload["data"].(map[string]interface{}); ok { - payload = data - } - choices, ok := payload["choices"].([]interface{}) - if !ok { - return "" - } - var chunks []string - for _, choice := range choices { - record, ok := choice.(map[string]interface{}) - if !ok { - continue - } - if message, ok := record["message"].(map[string]interface{}); ok { - if text := stringField(message, "content"); text != "" { - chunks = append(chunks, text) - } - } - if text := stringField(record, "text"); text != "" { - chunks = append(chunks, text) - } - } - return strings.Join(chunks, "") -} - -// firstRawString 返回第一个非空字符串,并原样保留首尾空白。流式增量逐 token 到达, -// 空格常常就在 token 开头(" user");用会 TrimSpace 的 firstNonEmptyString 会把词粘在一起。 -func firstRawString(values ...string) string { - for _, value := range values { - if value != "" { - return value - } - } - return "" -} diff --git a/backend/internal/app/provider_text_agent.go b/backend/internal/app/provider_text_agent.go new file mode 100644 index 000000000..060164208 --- /dev/null +++ b/backend/internal/app/provider_text_agent.go @@ -0,0 +1,452 @@ +// Agent 模型步的上游请求:OpenAI Responses / Chat Completions / Claude 三种协议的工具调用请求体、 +// 工具选择兼容降级与非流式响应解析。 +// +// 部分上游不支持 tool_choice=required 等写法,遇到兼容性错误时退回 auto 重试一次。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "strings" + + "infinite-canvas/backend/internal/model" + "infinite-canvas/backend/internal/protocol" +) + +func runAgentToolTask(ctx context.Context, input canvasGenerationInput) (map[string]interface{}, error) { + // 浏览器持久化的是协议中立请求;资源水合和模型路由完成后才展开上游协议, + // 防止供应商请求体反向污染任务记录,也避免切换模型时复用错误协议。 + if input.AgentRequests != nil && input.AgentRequests.Canonical != nil { + _, declarative := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType) + requests, err := expandCanonicalAgentRequest(input.AgentRequests.Canonical, input.Config, declarative) + if err != nil { + return nil, err + } + input.AgentRequests = requests + } + if adapter, ok := agentProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok { + return runDeclarativeAgentTask(ctx, input, adapter) + } + if input.AgentRequests == nil { + return nil, errors.New("画布 Agent 工具请求缺少协议参数") + } + request := input.AgentRequests.ChatCompletion + path := "/chat/completions" + protocol := "chat-completion" + if input.Config.InterfaceType == string(model.ChannelInterfaceOpenAIResponse) { + request = input.AgentRequests.Responses + path = "/responses" + protocol = "responses" + } else if input.Config.InterfaceType == string(model.ChannelInterfaceClaudeAPI) { + if input.AgentRequests.Claude != nil { + request = input.AgentRequests.Claude + } + path = "/messages" + protocol = "claude-api" + } + if request == nil { + return nil, errors.New("画布 Agent 工具请求缺少协议参数") + } + body := cloneStringAnyMap(request) + if protocol == "claude-api" && input.AgentRequests.Claude == nil { + body = claudeAgentBody(body) + } + body["model"] = input.Config.Model + applyTextThinking(body, input, protocol) + applyAgentOutputLimit(body, agentStepOutputLimit(input), protocol) + normalizeAgentToolChoice(body, input, protocol) + result, err := postAgentRequest(ctx, input, path, body, protocol) + if protocol == "chat-completion" && isAgentToolChoiceCompatibilityError(err) { + if !isAutoAgentToolChoice(body["tool_choice"]) { + autoBody := cloneStringAnyMap(body) + autoBody["tool_choice"] = "auto" + result, err = postAgentRequest(ctx, input, path, autoBody, protocol) + } + if isAgentToolChoiceCompatibilityError(err) { + withoutToolChoice := cloneStringAnyMap(body) + delete(withoutToolChoice, "tool_choice") + result, err = postAgentRequest(ctx, input, path, withoutToolChoice, protocol) + } + } + if err != nil { + return nil, err + } + return result, nil +} + +func postAgentRequest(ctx context.Context, input canvasGenerationInput, path string, body map[string]interface{}, protocol string) (map[string]interface{}, error) { + if input.StreamText { + return postStreamingAgent(ctx, input.Config, path, body, protocol, input.OnTextDelta, input.OnReasoningDelta) + } + delete(body, "stream") + var payload map[string]interface{} + if err := postJSON(ctx, input.Config, path, body, &payload); err != nil { + return nil, err + } + return parseAgentToolPayload(payload, protocol) +} + +// agentStepOutputLimit 取本次 Agent 调用的输出上限。 +// +// 两个来源语义相同但通道不同:textOptions.maxOutputTokens 是任务信封里下发的策略值 +// (画布 Agent 每步按运行时策略给,persist 在任务输入里,重启后仍在);input.MaxOutputTokens +// 是进程内直传的旧入口(渠道模型能力声明)。都为 0 时不写上限字段,交给上游按剩余上下文放行。 +// 都非零时取较小值:策略上限不该超过模型自己声明的物理上限。 +func agentStepOutputLimit(input canvasGenerationInput) int { + limits := []int{input.TextOptions.MaxOutputTokens, input.MaxOutputTokens} + limit := 0 + for _, candidate := range limits { + if candidate <= 0 { + continue + } + if limit == 0 || candidate < limit { + limit = candidate + } + } + return limit +} + +// applyAgentOutputLimit 按协议写入输出上限字段名:Claude 与 Chat Completions 用 max_tokens, +// Responses 用 max_output_tokens。 +func applyAgentOutputLimit(body map[string]interface{}, limit int, protocol string) { + if limit <= 0 { + return + } + field := "max_tokens" + if protocol == "responses" { + field = "max_output_tokens" + } + applyTextOutputLimit(body, limit, field) +} + +func runDeclarativeAgentTask(ctx context.Context, input canvasGenerationInput, adapter protocol.AgentAdapter) (map[string]interface{}, error) { + wire := input.Config.InterfaceType + if wire == string(model.ChannelInterfaceOpenAIResponse) { + wire = "responses" + } + knownWire := wire == "chat-completion" || wire == "responses" || wire == "claude-api" + if input.TextOptions.Thinking && !knownWire { + return nil, errors.New("当前声明式 Agent 渠道尚不支持思考模式,请关闭思考模式或切换内置协议渠道") + } + if input.AgentRequests == nil { + return nil, errors.New("画布 Agent 工具请求缺少协议参数") + } + request := map[string]any{ + "chatCompletion": input.AgentRequests.ChatCompletion, + "responses": input.AgentRequests.Responses, + "claude": input.AgentRequests.Claude, + "gemini": input.AgentRequests.Gemini, + } + spec, err := adapter.BuildAgent(ctx, protocol.AgentRequestContext{BaseURL: input.Config.BaseURL, Model: input.Config.Model, Request: request}) + if err != nil { + return nil, err + } + if knownWire { + body := protocolBodyObject(spec.Body) + if body == nil { + return nil, errors.New("声明式 Agent 请求体必须是 JSON 对象") + } + applyTextThinking(body, input, wire) + applyAgentOutputLimit(body, agentStepOutputLimit(input), wire) + normalizeAgentToolChoice(body, input, wire) + spec.Body = body + if input.StreamText { + body["stream"] = true + if wire == "chat-completion" { + if err := ensureChatCompletionStreamUsage(body); err != nil { + return nil, err + } + } + parser := newStreamingAgentParser(wire, input.OnTextDelta) + parser.emitReasoning = input.OnReasoningDelta + data, mime, err := executeProtocolBinaryRequestWithConsumer(ctx, input.Config, spec, parser.consume) + if err != nil { + return nil, err + } + if strings.Contains(strings.ToLower(mime), "event-stream") { + parser.flush() + return parser.result() + } + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err) + } + return parseAgentToolPayload(payload, wire) + } + } + body, err := executeDeclarativeAgentWithGeminiCache(ctx, input, spec) + if err != nil { + return nil, err + } + parsed, err := adapter.ParseAgent(ctx, body) + if err != nil { + return nil, err + } + result := map[string]interface{}{"mode": "text", "text": parsed.Text, "toolCalls": []interface{}{}} + if parsed.Reasoning != "" { + result["reasoning"] = parsed.Reasoning + } + calls := make([]interface{}, 0, len(parsed.ToolCalls)) + for _, call := range parsed.ToolCalls { + mapped := map[string]interface{}{ + "id": call.ID, + "type": "function", + "function": map[string]interface{}{"name": call.Name, "arguments": call.Arguments}, + } + if call.ThoughtSignature != "" { + mapped["thoughtSignature"] = call.ThoughtSignature + } + calls = append(calls, mapped) + } + result["toolCalls"] = calls + if strings.TrimSpace(parsed.Text) == "" && len(calls) == 0 { + return nil, errors.New("声明式 Agent 接口没有返回内容") + } + return result, nil +} + +// executeDeclarativeAgentWithGeminiCache keeps explicit Prompt Cache entirely +// optional: cache creation, cleanup, and a single stale-cache rebuild can never +// turn a valid uncached Agent request into a failed run. +func executeDeclarativeAgentWithGeminiCache(ctx context.Context, input canvasGenerationInput, spec protocol.RequestSpec) ([]byte, error) { + baseSpec, err := cloneProtocolRequestSpec(spec) + if err != nil { + return nil, err + } + executeSpec := baseSpec + cacheUsed := false + if input.Config.InterfaceType == officialGeminiAgentInterface { + prepared, used, prepareErr := prepareOfficialGeminiAgentCache(ctx, input, baseSpec) + if prepareErr != nil { + log.Printf("gemini agent prompt cache unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr)) + } else { + executeSpec, cacheUsed = prepared, used + } + } + body, err := executeProtocolRequest(ctx, input.Config, executeSpec) + if err == nil || input.Config.InterfaceType != officialGeminiAgentInterface || !cacheUsed || !geminiRequestUsesCachedContent(executeSpec) || !isGeminiCachedContentNotFound(err, stringValue(protocolBodyObject(executeSpec.Body)["cachedContent"])) { + return body, err + } + + resourceName := "" + if body := protocolBodyObject(executeSpec.Body); body != nil { + resourceName, _ = body["cachedContent"].(string) + } + if invalidateErr := invalidateOfficialGeminiAgentCache(ctx, input, baseSpec, resourceName); invalidateErr != nil { + // The local identity is still removed whenever possible. Do not replace a + // provider 404 with a cache bookkeeping error or skip the one rebuild. + log.Printf("gemini agent prompt cache invalidation failed: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(invalidateErr)) + } + rebuilt, _, prepareErr := prepareOfficialGeminiAgentCacheMode(ctx, input, baseSpec, true) + if prepareErr != nil { + log.Printf("gemini agent prompt cache rebuild unavailable: model=%s reason=%s", safeProviderModel(input.Config.Model), safeProviderLogError(prepareErr)) + rebuilt = baseSpec + } + // The rebuilt request is intentionally executed only once. If it receives + // another CachedContent 404, the first stale-cache recovery already happened; + // preserve the provider error instead of recursively rebuilding forever. + return executeProtocolRequest(ctx, input.Config, rebuilt) +} + +func claudeAgentBody(request map[string]interface{}) map[string]interface{} { + body := map[string]interface{}{"max_tokens": 4096} + if messages, ok := request["messages"].([]interface{}); ok { + claudeMessages := make([]interface{}, 0, len(messages)) + var system []string + for _, value := range messages { + message, _ := value.(map[string]interface{}) + role := strings.ToLower(strings.TrimSpace(stringField(message, "role"))) + if role == "system" { + if content := strings.TrimSpace(fmt.Sprint(message["content"])); content != "" { + system = append(system, content) + } + continue + } + if role == "tool" { + claudeMessages = append(claudeMessages, map[string]interface{}{"role": "user", "content": []interface{}{map[string]interface{}{ + "type": "tool_result", "tool_use_id": stringField(message, "tool_call_id"), "content": fmt.Sprint(message["content"]), + }}}) + continue + } + role = mapClaudeMessageRole(role) + content := message["content"] + if content == nil { + content = "" + } + if toolCalls, ok := message["tool_calls"].([]interface{}); ok && len(toolCalls) > 0 { + blocks := make([]interface{}, 0, len(toolCalls)) + for _, value := range toolCalls { + toolCall, _ := value.(map[string]interface{}) + function, _ := toolCall["function"].(map[string]interface{}) + blocks = append(blocks, map[string]interface{}{ + "type": "tool_use", "id": stringField(toolCall, "id"), "name": stringField(function, "name"), "input": claudeToolInput(function["arguments"]), + }) + } + content = blocks + } + claudeMessages = append(claudeMessages, map[string]interface{}{"role": role, "content": content}) + } + body["messages"] = claudeMessages + if len(system) > 0 { + body["system"] = []interface{}{map[string]interface{}{ + "type": "text", "text": strings.Join(system, "\n\n"), + "cache_control": map[string]interface{}{"type": "ephemeral"}, + }} + } + } + if tools, ok := request["tools"].([]interface{}); ok && len(tools) > 0 { + claudeTools := make([]interface{}, 0, len(tools)) + for _, value := range tools { + tool, _ := value.(map[string]interface{}) + function, _ := tool["function"].(map[string]interface{}) + if len(function) == 0 { + continue + } + claudeTools = append(claudeTools, map[string]interface{}{ + "name": stringField(function, "name"), "description": stringField(function, "description"), "input_schema": function["parameters"], + }) + } + if len(claudeTools) > 0 { + if last, ok := claudeTools[len(claudeTools)-1].(map[string]interface{}); ok { + last["cache_control"] = map[string]interface{}{"type": "ephemeral"} + } + body["tools"] = claudeTools + } + } + if choice, ok := request["tool_choice"]; ok { + body["tool_choice"] = claudeToolChoice(choice) + } + return body +} + +func mapClaudeMessageRole(role string) string { + if role == "assistant" { + return "assistant" + } + return "user" +} + +func claudeToolInput(value interface{}) interface{} { + if raw, ok := value.(string); ok { + var parsed interface{} + if json.Unmarshal([]byte(raw), &parsed) == nil && parsed != nil { + return parsed + } + } + if value != nil { + return value + } + return map[string]interface{}{} +} + +func claudeToolChoice(value interface{}) interface{} { + switch choice := value.(type) { + case string: + switch strings.ToLower(strings.TrimSpace(choice)) { + case "required": + return map[string]interface{}{"type": "any"} + case "none": + return map[string]interface{}{"type": "auto"} + default: + return map[string]interface{}{"type": "auto"} + } + case map[string]interface{}: + if function, ok := choice["function"].(map[string]interface{}); ok && stringField(function, "name") != "" { + return map[string]interface{}{"type": "tool", "name": stringField(function, "name")} + } + if name := stringField(choice, "name"); name != "" { + return map[string]interface{}{"type": "tool", "name": name} + } + } + return map[string]interface{}{"type": "auto"} +} + +func isAgentToolChoiceCompatibilityError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + var payloadErr providerPayloadError + if errors.As(err, &payloadErr) { + message += " " + strings.ToLower(payloadErr.raw) + } + var httpErr providerHTTPError + if errors.As(err, &httpErr) { + message += " " + strings.ToLower(httpErr.Body) + } + return strings.Contains(message, "tool_choice") || strings.Contains(message, "tool choice") || strings.Contains(message, "tool-choice") || strings.Contains(message, "thinking mode") +} + +func isAutoAgentToolChoice(value interface{}) bool { + choice, ok := value.(string) + return ok && strings.EqualFold(strings.TrimSpace(choice), "auto") +} + +func parseAgentToolPayload(payload map[string]interface{}, protocol string) (map[string]interface{}, error) { + if err := validateTextPayload(payload); err != nil { + return nil, err + } + result := map[string]interface{}{"mode": "text", "text": "", "toolCalls": []interface{}{}} + if protocol == "responses" { + result["text"] = firstNonEmptyString(stringField(payload, "output_text"), extractResponseText(payload)) + if reasoning := extractResponseReasoning(payload); reasoning != "" { + result["reasoning"] = reasoning + } + calls := make([]interface{}, 0) + for _, value := range interfaceSlice(payload["output"]) { + item, _ := value.(map[string]interface{}) + if stringField(item, "type") != "function_call" { + continue + } + calls = append(calls, map[string]interface{}{"id": firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": stringField(item, "arguments")}}) + } + result["toolCalls"] = calls + return result, nil + } + if protocol == "claude-api" { + content := interfaceSlice(payload["content"]) + calls := make([]interface{}, 0) + for _, value := range content { + item, _ := value.(map[string]interface{}) + switch stringField(item, "type") { + case "text": + result["text"] = result["text"].(string) + stringField(item, "text") + case "thinking": + result["reasoning"] = resultString(result, "reasoning") + firstNonEmptyString(stringField(item, "thinking"), stringField(item, "text")) + case "tool_use": + arguments, err := json.Marshal(item["input"]) + if err != nil { + return nil, err + } + calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(item, "name"), "arguments": string(arguments)}}) + } + } + result["toolCalls"] = calls + if result["text"] == "" && len(calls) == 0 { + return nil, errors.New("Claude Agent 接口没有返回内容") + } + return result, nil + } + choices := interfaceSlice(payload["choices"]) + if len(choices) == 0 { + return nil, errors.New("画布 Agent 接口没有返回 choices") + } + choice, _ := choices[0].(map[string]interface{}) + message, _ := choice["message"].(map[string]interface{}) + result["text"] = stringField(message, "content") + if reasoning := firstNonEmptyString(stringField(message, "reasoning_content"), stringField(message, "reasoning")); reasoning != "" { + result["reasoning"] = reasoning + } + calls := make([]interface{}, 0) + for _, value := range interfaceSlice(message["tool_calls"]) { + item, _ := value.(map[string]interface{}) + function, _ := item["function"].(map[string]interface{}) + calls = append(calls, map[string]interface{}{"id": stringField(item, "id"), "type": "function", "function": map[string]interface{}{"name": stringField(function, "name"), "arguments": stringField(function, "arguments")}}) + } + result["toolCalls"] = calls + return result, nil +} diff --git a/backend/internal/app/provider_text_agent_stream.go b/backend/internal/app/provider_text_agent_stream.go new file mode 100644 index 000000000..e2c2cbf93 --- /dev/null +++ b/backend/internal/app/provider_text_agent_stream.go @@ -0,0 +1,354 @@ +// Agent 模型步的流式(SSE)解析:把三种协议的增量事件拼成完整的文本、推理与工具调用。 +// +// 工具调用参数按 index/ID 逐段拼接;解析器只在 flush 时产出结果,中途断流视为失败而不是截断成功。 + +package app + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "math" + "sort" + "strings" +) + +func postStreamingAgent(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string), onReasoning ...func(string)) (map[string]interface{}, error) { + body["stream"] = true + if protocol == "chat-completion" { + if err := ensureChatCompletionStreamUsage(body); err != nil { + return nil, err + } + } + parser := newStreamingAgentParser(protocol, onDelta) + if len(onReasoning) > 0 { + parser.emitReasoning = onReasoning[0] + } + data, mimeType, err := postStreamingBinary(ctx, config, path, body, parser.consume) + if err != nil { + return nil, err + } + if !strings.Contains(strings.ToLower(mimeType), "event-stream") { + var payload map[string]interface{} + if err := json.Unmarshal(data, &payload); err != nil { + return nil, fmt.Errorf("Agent 接口返回格式无效:%w", err) + } + return parseAgentToolPayload(payload, protocol) + } + parser.flush() + return parser.result() +} + +type streamingAgentToolCall struct { + id string + name string + arguments string +} + +type streamingAgentParser struct { + protocol string + buffer string + text strings.Builder + reasoning strings.Builder + toolCalls map[int]*streamingAgentToolCall + toolCallByID map[string]int + completed map[string]interface{} + err error + emit func(string) + emitReasoning func(string) +} + +func newStreamingAgentParser(protocol string, emit func(string)) *streamingAgentParser { + return &streamingAgentParser{protocol: protocol, toolCalls: map[int]*streamingAgentToolCall{}, toolCallByID: map[string]int{}, emit: emit} +} + +func (p *streamingAgentParser) consume(mimeType string, chunk []byte) { + if p == nil || p.err != nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 { + return + } + p.buffer += string(chunk) + p.consumeFrames(false) +} + +func (p *streamingAgentParser) flush() { + if p == nil || p.err != nil { + return + } + p.consumeFrames(true) +} + +func (p *streamingAgentParser) consumeFrames(flush bool) { + for p.err == nil { + match := sseFrameBoundaryPattern.FindStringIndex(p.buffer) + if match == nil { + break + } + p.consumeFrame(p.buffer[:match[0]]) + p.buffer = p.buffer[match[1]:] + } + if flush && p.err == nil && strings.TrimSpace(p.buffer) != "" { + p.consumeFrame(p.buffer) + p.buffer = "" + } +} + +func (p *streamingAgentParser) consumeFrame(frame string) { + var eventName string + var dataLines []string + for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") { + switch { + case strings.HasPrefix(line, "event:"): + eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")) + } + } + raw := strings.TrimSpace(strings.Join(dataLines, "\n")) + if raw == "" || raw == "[DONE]" { + return + } + var payload map[string]interface{} + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + p.err = fmt.Errorf("Agent 流式事件解析失败:%w", err) + return + } + if err := validateTextPayload(payload); err != nil { + p.err = err + return + } + switch p.protocol { + case "responses": + p.consumeResponsesEvent(eventName, payload) + case "claude-api": + p.consumeClaudeEvent(payload) + default: + p.consumeChatCompletionEvent(payload) + } +} + +func (p *streamingAgentParser) consumeResponsesEvent(eventName string, payload map[string]interface{}) { + eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type")) + switch eventType { + case "response.output_text.delta", "output_text.delta": + p.appendText(stringField(payload, "delta")) + case "response.reasoning.delta", "response.reasoning_text.delta", "response.reasoning_summary_text.delta": + p.appendReasoning(stringField(payload, "delta")) + case "response.completed": + p.completed, _ = payload["response"].(map[string]interface{}) + case "response.output_item.added": + item, _ := payload["item"].(map[string]interface{}) + if stringField(item, "type") == "function_call" { + index := intField(payload, "output_index", len(p.toolCalls)) + p.setToolCall(index, firstNonEmptyString(stringField(item, "call_id"), stringField(item, "id")), stringField(item, "name"), stringField(item, "arguments")) + } + case "response.function_call_arguments.delta": + index := p.responseToolCallIndex(payload) + p.toolCall(index).arguments += stringField(payload, "delta") + case "response.function_call_arguments.done": + index := p.responseToolCallIndex(payload) + if arguments := stringField(payload, "arguments"); arguments != "" { + p.toolCall(index).arguments = arguments + } + } +} + +func (p *streamingAgentParser) responseToolCallIndex(payload map[string]interface{}) int { + itemID := firstNonEmptyString(stringField(payload, "item_id"), stringField(payload, "call_id")) + if index, ok := p.toolCallByID[itemID]; ok { + return index + } + return intField(payload, "output_index", len(p.toolCalls)) +} + +func (p *streamingAgentParser) consumeChatCompletionEvent(payload map[string]interface{}) { + choices, _ := payload["choices"].([]interface{}) + for _, value := range choices { + choice, _ := value.(map[string]interface{}) + delta, _ := choice["delta"].(map[string]interface{}) + p.appendText(streamContentText(delta["content"])) + p.appendReasoning(firstRawString(stringField(delta, "reasoning_content"), stringField(delta, "reasoning"), stringField(delta, "reasoning_text"))) + for fallbackIndex, toolValue := range interfaceSlice(delta["tool_calls"]) { + tool, _ := toolValue.(map[string]interface{}) + index := intField(tool, "index", fallbackIndex) + function, _ := tool["function"].(map[string]interface{}) + current := p.toolCall(index) + if id := stringField(tool, "id"); id != "" { + current.id = id + p.toolCallByID[id] = index + } + if name := stringField(function, "name"); name != "" { + current.name += name + } + current.arguments += stringField(function, "arguments") + } + } +} + +func (p *streamingAgentParser) consumeClaudeEvent(payload map[string]interface{}) { + switch stringField(payload, "type") { + case "content_block_start": + block, _ := payload["content_block"].(map[string]interface{}) + index := intField(payload, "index", len(p.toolCalls)) + switch stringField(block, "type") { + case "text": + p.appendText(stringField(block, "text")) + case "thinking": + p.appendReasoning(firstRawString(stringField(block, "thinking"), stringField(block, "text"))) + case "tool_use": + arguments := "" + if input := block["input"]; input != nil { + if encoded, err := json.Marshal(input); err == nil && string(encoded) != "{}" { + arguments = string(encoded) + } + } + p.setToolCall(index, stringField(block, "id"), stringField(block, "name"), arguments) + } + case "content_block_delta": + delta, _ := payload["delta"].(map[string]interface{}) + index := intField(payload, "index", len(p.toolCalls)-1) + if stringField(delta, "type") == "text_delta" { + p.appendText(stringField(delta, "text")) + } + if stringField(delta, "type") == "thinking_delta" { + p.appendReasoning(firstRawString(stringField(delta, "thinking"), stringField(delta, "text"))) + } + if stringField(delta, "type") == "input_json_delta" { + p.toolCall(index).arguments += stringField(delta, "partial_json") + } + case "error": + errValue, _ := payload["error"].(map[string]interface{}) + p.err = errors.New(defaultString(stringField(errValue, "message"), "Claude 上游返回失败")) + } +} + +func resultString(value map[string]interface{}, key string) string { + return stringField(value, key) +} + +func (p *streamingAgentParser) appendText(delta string) { + if delta == "" { + return + } + p.text.WriteString(delta) + if p.emit != nil { + p.emit(delta) + } +} + +func (p *streamingAgentParser) appendReasoning(delta string) { + if delta == "" { + return + } + p.reasoning.WriteString(delta) + if p.emitReasoning != nil { + p.emitReasoning(delta) + } +} + +func (p *streamingAgentParser) toolCall(index int) *streamingAgentToolCall { + if index < 0 { + index = 0 + } + if p.toolCalls[index] == nil { + p.toolCalls[index] = &streamingAgentToolCall{} + } + return p.toolCalls[index] +} + +func (p *streamingAgentParser) setToolCall(index int, id string, name string, arguments string) { + call := p.toolCall(index) + call.id, call.name, call.arguments = id, name, arguments + if id != "" { + p.toolCallByID[id] = index + } +} + +func (p *streamingAgentParser) result() (map[string]interface{}, error) { + if p.err != nil { + return nil, p.err + } + if p.completed != nil { + result, err := parseAgentToolPayload(p.completed, p.protocol) + if err != nil { + return nil, err + } + if p.text.Len() > 0 { + result["text"] = p.text.String() + } + if p.reasoning.Len() > 0 { + result["reasoning"] = p.reasoning.String() + } + return result, nil + } + result := map[string]interface{}{"mode": "text", "text": p.text.String(), "toolCalls": []interface{}{}} + if p.reasoning.Len() > 0 { + result["reasoning"] = p.reasoning.String() + } + indices := make([]int, 0, len(p.toolCalls)) + for index := range p.toolCalls { + indices = append(indices, index) + } + sort.Ints(indices) + calls := make([]interface{}, 0, len(indices)) + for _, index := range indices { + call := p.toolCalls[index] + if call == nil || strings.TrimSpace(call.id) == "" || strings.TrimSpace(call.name) == "" { + continue + } + arguments := call.arguments + if strings.TrimSpace(arguments) == "" { + arguments = "{}" + } + var parsed interface{} + if err := json.Unmarshal([]byte(arguments), &parsed); err != nil { + return nil, fmt.Errorf("Agent 工具参数不是完整 JSON:%w", err) + } + calls = append(calls, map[string]interface{}{"id": call.id, "type": "function", "function": map[string]interface{}{"name": call.name, "arguments": arguments}}) + } + result["toolCalls"] = calls + if p.text.Len() == 0 && len(calls) == 0 { + return nil, errors.New("画布 Agent 接口没有返回内容") + } + return result, nil +} + +func intField(value map[string]interface{}, key string, fallback int) int { + number, ok := value[key].(float64) + if !ok || math.IsNaN(number) || math.IsInf(number, 0) { + return fallback + } + return int(number) +} + +func extractResponseReasoning(payload map[string]interface{}) string { + var chunks []string + for _, value := range interfaceSlice(payload["output"]) { + item, _ := value.(map[string]interface{}) + if stringField(item, "type") != "reasoning" { + continue + } + for _, key := range []string{"summary", "content"} { + for _, part := range interfaceSlice(item[key]) { + record, _ := part.(map[string]interface{}) + if text := strings.TrimSpace(stringField(record, "text")); text != "" { + chunks = append(chunks, text) + } + } + } + } + return strings.Join(chunks, "\n") +} + +func interfaceSlice(value interface{}) []interface{} { + items, _ := value.([]interface{}) + return items +} + +func cloneStringAnyMap(value map[string]interface{}) map[string]interface{} { + cloned := make(map[string]interface{}, len(value)+1) + for key, item := range value { + cloned[key] = item + } + return cloned +} diff --git a/backend/internal/app/provider_text_stream.go b/backend/internal/app/provider_text_stream.go new file mode 100644 index 000000000..a828d0a34 --- /dev/null +++ b/backend/internal/app/provider_text_stream.go @@ -0,0 +1,327 @@ +// 普通文本任务的流式解析与增量推送(文本节点、提示词优化等非 Agent 场景)。 + +package app + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +func requestTextProvider(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, stream bool, onDelta func(string)) (providerTextResult, error) { + if stream { + return postStreamingTextResult(ctx, config, path, body, protocol, onDelta) + } + var payload map[string]interface{} + if err := postJSON(ctx, config, path, body, &payload); err != nil { + return providerTextResult{}, err + } + parsed, err := parseAgentToolPayload(payload, protocol) + if err != nil { + return providerTextResult{}, err + } + result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")} + if result.Text == "" { + return providerTextResult{}, errors.New("文本接口没有返回内容") + } + return result, nil +} + +func postStreamingText(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (string, error) { + result, err := postStreamingTextResult(ctx, config, path, body, protocol, onDelta) + return result.Text, err +} + +func postStreamingTextResult(ctx context.Context, config providerConfig, path string, body map[string]interface{}, protocol string, onDelta func(string)) (providerTextResult, error) { + // 文本创作与 Agent 共用同一套 SSE 解析,确保正文、推理摘要和供应商错误语义一致。 + parsed, err := postStreamingAgent(ctx, config, path, body, protocol, onDelta) + if err != nil { + return providerTextResult{}, err + } + result := providerTextResult{Text: stringField(parsed, "text"), Reasoning: stringField(parsed, "reasoning")} + if result.Text == "" { + return providerTextResult{}, errors.New("流式文本接口没有返回内容") + } + return result, nil +} + +func extractTextPayload(payload map[string]interface{}, protocol string) string { + if protocol == "claude-api" { + content, _ := payload["content"].([]interface{}) + var result strings.Builder + for _, item := range content { + record, _ := item.(map[string]interface{}) + if stringField(record, "type") == "text" { + result.WriteString(stringField(record, "text")) + } + } + return result.String() + } + if protocol == "responses" { + text := stringField(payload, "output_text") + if text == "" { + text = extractResponseText(payload) + } + return text + } + return extractChatCompletionText(payload) +} + +func validateTextPayload(payload map[string]interface{}) error { + if code, ok := payload["code"].(float64); ok && code != 0 { + rawMessage := defaultString(stringField(payload, "msg"), "请求失败") + return providerPayloadError{raw: rawMessage, message: providerPayloadErrorMessage(rawMessage)} + } + if errValue, ok := payload["error"].(map[string]interface{}); ok { + if message := stringField(errValue, "message"); message != "" { + return providerPayloadError{raw: message, message: providerPayloadErrorMessage(message)} + } + } + return nil +} + +func parseTextEventStream(data []byte, protocol string) (string, error) { + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 64<<10), len(data)+1) + var text strings.Builder + var eventName string + var dataLines []string + + flush := func() error { + if len(dataLines) == 0 { + eventName = "" + return nil + } + raw := strings.TrimSpace(strings.Join(dataLines, "\n")) + dataLines = nil + if raw == "" || raw == "[DONE]" { + eventName = "" + return nil + } + var payload map[string]interface{} + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + return fmt.Errorf("流式文本事件解析失败:%w", err) + } + if eventName == "error" { + if err := validateTextPayload(payload); err != nil { + return err + } + return errors.New("上游流式文本请求失败") + } + if err := validateTextPayload(payload); err != nil { + return err + } + if protocol == "responses" { + text.WriteString(stringField(payload, "delta")) + } else if protocol == "claude-api" { + delta, _ := payload["delta"].(map[string]interface{}) + if stringField(delta, "type") == "text_delta" { + text.WriteString(stringField(delta, "text")) + } + } else { + choices, _ := payload["choices"].([]interface{}) + for _, choice := range choices { + record, _ := choice.(map[string]interface{}) + delta, _ := record["delta"].(map[string]interface{}) + text.WriteString(streamContentText(delta["content"])) + } + } + eventName = "" + return nil + } + + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + if err := flush(); err != nil { + return "", err + } + continue + } + switch { + case strings.HasPrefix(line, "event:"): + eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + value := strings.TrimPrefix(line, "data:") + dataLines = append(dataLines, strings.TrimPrefix(value, " ")) + } + } + if err := scanner.Err(); err != nil { + return "", fmt.Errorf("读取流式文本响应失败:%w", err) + } + if err := flush(); err != nil { + return "", err + } + if text.Len() == 0 { + return "", errors.New("流式文本接口没有返回内容") + } + return text.String(), nil +} + +func streamContentText(value interface{}) string { + if text, ok := value.(string); ok { + return text + } + parts, ok := value.([]interface{}) + if !ok { + return "" + } + var result strings.Builder + for _, part := range parts { + record, _ := part.(map[string]interface{}) + result.WriteString(stringField(record, "text")) + } + return result.String() +} + +type streamingTextDeltaParser struct { + protocol string + buffer string + emit func(string) +} + +func newStreamingTextDeltaParser(protocol string, emit func(string)) *streamingTextDeltaParser { + return &streamingTextDeltaParser{protocol: protocol, emit: emit} +} + +func (p *streamingTextDeltaParser) consume(mimeType string, chunk []byte) { + if p == nil || p.emit == nil || !strings.Contains(strings.ToLower(mimeType), "event-stream") || len(chunk) == 0 { + return + } + p.buffer += string(chunk) + p.consumeFrames(false) +} + +func (p *streamingTextDeltaParser) flush() { + if p == nil || p.emit == nil { + return + } + p.consumeFrames(true) +} + +func (p *streamingTextDeltaParser) consumeFrames(flush bool) { + for { + match := sseFrameBoundaryPattern.FindStringIndex(p.buffer) + if match == nil { + break + } + p.consumeFrame(p.buffer[:match[0]]) + p.buffer = p.buffer[match[1]:] + } + if flush && strings.TrimSpace(p.buffer) != "" { + p.consumeFrame(p.buffer) + p.buffer = "" + } +} + +func (p *streamingTextDeltaParser) consumeFrame(frame string) { + var eventName string + var dataLines []string + for _, line := range strings.Split(strings.ReplaceAll(frame, "\r\n", "\n"), "\n") { + switch { + case strings.HasPrefix(line, "event:"): + eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:")) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } + raw := strings.TrimSpace(strings.Join(dataLines, "\n")) + if raw == "" || raw == "[DONE]" { + return + } + var payload map[string]interface{} + if json.Unmarshal([]byte(raw), &payload) != nil { + return + } + if delta := streamingTextDelta(p.protocol, eventName, payload); delta != "" { + p.emit(delta) + } +} + +func streamingTextDelta(protocol string, eventName string, payload map[string]interface{}) string { + if protocol == "responses" { + eventType := firstNonEmptyString(strings.TrimSpace(eventName), stringField(payload, "type")) + if eventType == "response.output_text.delta" || eventType == "output_text.delta" || eventType == "" || eventType == "message" { + return stringField(payload, "delta") + } + return "" + } + if protocol == "claude-api" { + delta, _ := payload["delta"].(map[string]interface{}) + if stringField(delta, "type") == "text_delta" { + return stringField(delta, "text") + } + return "" + } + choices, _ := payload["choices"].([]interface{}) + var text strings.Builder + for _, choice := range choices { + record, _ := choice.(map[string]interface{}) + delta, _ := record["delta"].(map[string]interface{}) + text.WriteString(streamContentText(delta["content"])) + } + return text.String() +} + +func extractResponseText(payload map[string]interface{}) string { + output, ok := payload["output"].([]interface{}) + if !ok { + return "" + } + var chunks []string + for _, item := range output { + record, ok := item.(map[string]interface{}) + if !ok || record["type"] != "message" { + continue + } + content, _ := record["content"].([]interface{}) + for _, part := range content { + partRecord, ok := part.(map[string]interface{}) + if ok && stringField(partRecord, "text") != "" { + chunks = append(chunks, stringField(partRecord, "text")) + } + } + } + return strings.Join(chunks, "") +} + +func extractChatCompletionText(payload map[string]interface{}) string { + if data, ok := payload["data"].(map[string]interface{}); ok { + payload = data + } + choices, ok := payload["choices"].([]interface{}) + if !ok { + return "" + } + var chunks []string + for _, choice := range choices { + record, ok := choice.(map[string]interface{}) + if !ok { + continue + } + if message, ok := record["message"].(map[string]interface{}); ok { + if text := stringField(message, "content"); text != "" { + chunks = append(chunks, text) + } + } + if text := stringField(record, "text"); text != "" { + chunks = append(chunks, text) + } + } + return strings.Join(chunks, "") +} + +// firstRawString 返回第一个非空字符串,并原样保留首尾空白。流式增量逐 token 到达, +// 空格常常就在 token 开头(" user");用会 TrimSpace 的 firstNonEmptyString 会把词粘在一起。 +func firstRawString(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/backend/internal/app/resource.go b/backend/internal/app/resource.go index ee64d2f52..1b7ceac67 100644 --- a/backend/internal/app/resource.go +++ b/backend/internal/app/resource.go @@ -1,40 +1,29 @@ package app import ( - "bytes" "crypto/hmac" "crypto/sha256" "encoding/base64" - "encoding/hex" - "encoding/json" "errors" - "fmt" - "image" _ "image/gif" _ "image/jpeg" _ "image/png" - "infinite-canvas/backend/internal/kernel" - "io" - "mime" - "mime/multipart" - "net/http" "net/url" "os" "path" - "path/filepath" "strconv" "strings" "time" "infinite-canvas/backend/internal/assets" + "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" - - "gorm.io/gorm" ) var errInvalidGeneratedDataURL = errors.New("生成内容 data URL 无效") type ResourceStream = assets.ResourceStream + type ResourceDelivery = assets.ResourceDelivery func (s *Service) Resources(userID string, limit int) ([]model.Resource, error) { @@ -115,826 +104,6 @@ func validatePublicResourceBaseURL(raw string) (*url.URL, error) { return parsed, nil } -func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { - return s.uploadResource(userID, header, kind, width, height, durationMs, false, uploadIdentity...) -} - -// uploadLocalResource keeps small system-owned assets on the server even when -// the uploader or platform has enabled object storage. The resource still uses -// the normal database record and persistent data directory lifecycle. -func (s *Service) uploadLocalResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { - return s.uploadResource(userID, header, kind, width, height, durationMs, true, uploadIdentity...) -} - -func (s *Service) uploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, forceLocal bool, uploadIdentity ...string) (*model.Resource, error) { - if header == nil { - return nil, BadAuthRequest("请选择要上传的文件") - } - uploadKey := normalizedResourceUploadKey(uploadIdentity) - existing, err := s.resourceForUploadKey(userID, uploadKey) - if err != nil { - return nil, err - } - if existing != nil && existing.Status == model.ResourceStatusReady { - return existing, nil - } - if existing != nil && existing.Status == model.ResourceStatusPending { - return nil, resourceUploadInProgress() - } - file, err := header.Open() - if err != nil { - return nil, err - } - defer file.Close() - - mimeType := strings.TrimSpace(header.Header.Get("Content-Type")) - mimeType = detectUploadedMimeType(file, header.Filename, mimeType) - if existing != nil { - return s.retryStoredResource(userID, existing, kind, mimeType, header.Size, file) - } - day, err := s.reserveUserUploadQuota(userID, header.Size) - if err != nil { - return nil, err - } - resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey, forceLocal) - if err != nil { - s.releaseUserUploadQuota(userID, day, header.Size) - } else if stored { - s.commitUserUploadQuota(userID, header.Size) - } else { - s.releaseUserUploadQuota(userID, day, header.Size) - } - return resource, err -} - -// UploadResourceFile 接收已完整落盘的本地文件(分片上传合并后调用)。 -// 它与 UploadResource 共享资源幂等、媒体探测、配额和持久化语义,唯一差异是分片会话已在 handler 校验单文件上限, -// 因而此处不再重复该上限检查;uploadIdentity 用于跨请求重试时复用同一逻辑资源,避免重复对象。 -func (s *Service) UploadResourceFile(userID string, fileName string, size int64, kind string, width int, height int, durationMs int64, file io.ReadSeeker, uploadIdentity ...string) (*model.Resource, error) { - if file == nil || size <= 0 { - return nil, BadAuthRequest("请选择要上传的文件") - } - uploadKey := normalizedResourceUploadKey(uploadIdentity) - existing, err := s.resourceForUploadKey(userID, uploadKey) - if err != nil { - return nil, err - } - if existing != nil && existing.Status == model.ResourceStatusReady { - return existing, nil - } - if existing != nil && existing.Status == model.ResourceStatusPending { - return nil, resourceUploadInProgress() - } - mimeType := detectUploadedMimeType(file, fileName, "") - if existing != nil { - return s.retryStoredResource(userID, existing, kind, mimeType, size, file) - } - day, err := s.reserveChunkedUploadQuota(userID, size) - if err != nil { - return nil, err - } - resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey, false) - if err != nil { - s.releaseUserUploadQuota(userID, day, size) - } else if stored { - s.commitUserUploadQuota(userID, size) - } else { - s.releaseUserUploadQuota(userID, day, size) - } - return resource, err -} - -func detectUploadedMimeType(file io.ReadSeeker, fileName string, declared string) string { - declared = strings.TrimSpace(strings.Split(declared, ";")[0]) - if declared != "" && declared != "application/octet-stream" { - return declared - } - buffer := make([]byte, 512) - read, _ := file.Read(buffer) - _, _ = file.Seek(0, io.SeekStart) - if detected := http.DetectContentType(buffer[:read]); detected != "" && detected != "application/octet-stream" { - return strings.TrimSpace(strings.Split(detected, ";")[0]) - } - if fromExtension := mime.TypeByExtension(filepath.Ext(fileName)); fromExtension != "" { - return strings.TrimSpace(strings.Split(fromExtension, ";")[0]) - } - return "application/octet-stream" -} - -func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { - uploadKey := normalizedResourceUploadKey(uploadIdentity) - existing, err := s.resourceForUploadKey(userID, uploadKey) - if err != nil { - return nil, err - } - if existing != nil && existing.Status == model.ResourceStatusReady { - return existing, nil - } - if existing != nil && existing.Status == model.ResourceStatusPending { - return nil, resourceUploadInProgress() - } - policy, err := s.RuntimePolicy() - if err != nil { - return nil, err - } - payload, err := downloadRemoteResource(rawURL, megabytes(policy.Resource.ResourceUploadMB)) - if err != nil { - return nil, err - } - kind = normalizeResourceKind(kind, payload.mimeType) - if kind == "image" && (width <= 0 || height <= 0) { - if decodedWidth, decodedHeight := imageDimensions(payload.data); decodedWidth > 0 && decodedHeight > 0 { - width = decodedWidth - height = decodedHeight - } - } - size := int64(len(payload.data)) - if existing != nil { - return s.retryStoredResource(userID, existing, kind, payload.mimeType, size, bytes.NewReader(payload.data)) - } - day, err := s.reserveUserUploadQuota(userID, size) - if err != nil { - return nil, err - } - resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey, false) - if err != nil { - s.releaseUserUploadQuota(userID, day, size) - } else if stored { - s.commitUserUploadQuota(userID, size) - } else { - s.releaseUserUploadQuota(userID, day, size) - } - return resource, err -} - -func normalizedResourceUploadKey(values []string) *string { - if len(values) == 0 || strings.TrimSpace(values[0]) == "" { - return nil - } - digest := sha256.Sum256([]byte(strings.TrimSpace(values[0]))) - value := hex.EncodeToString(digest[:]) - return &value -} - -func (s *Service) resourceForUploadKey(userID string, uploadKey *string) (*model.Resource, error) { - if uploadKey == nil { - return nil, nil - } - resource, err := s.repo.ResourceByUploadKey(userID, *uploadKey) - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, nil - } - return resource, err -} - -func resourceUploadInProgress() *AppError { - err := NewAppError(http.StatusConflict, "相同素材正在上传,请稍后重试") - err.Retryable = true - return err -} - -func (s *Service) OpenResource(userID string, id string) (*model.Resource, io.ReadCloser, error) { - stream, err := s.OpenResourceRange(userID, id, "") - if err != nil { - return nil, nil, err - } - return stream.Resource, stream.Body, nil -} - -func (s *Service) OpenResourceRange(userID string, id string, rangeHeader string) (*ResourceStream, error) { - resource, err := s.repo.ResourceForUser(userID, id) - if err != nil { - return nil, err - } - return s.openResourceRange(userID, resource, rangeHeader) -} - -func (s *Service) openResourceRange(userID string, resource *model.Resource, rangeHeader string) (*ResourceStream, error) { - if resource.Status != model.ResourceStatusReady { - return nil, BadAuthRequest("资源尚未上传完成") - } - if resource.Provider == "local" { - body, err := os.Open(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))) - if err != nil { - return nil, err - } - return &ResourceStream{Resource: resource, Body: body, StatusCode: http.StatusOK, ContentLength: resource.Size, AcceptRanges: "bytes"}, nil - } - setting, err := s.ossSettingForResource(userID, resource) - if err != nil { - return nil, err - } - if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { - return nil, errors.New("对象存储访问密钥不可用") - } - setting.Provider = firstNonEmpty(resource.Provider, setting.Provider) - setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) - setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket) - setting = s.storageSettingWithRuntimePolicy(setting) - stream, err := getOriginOSSObjectRange(setting, resource.ObjectKey, normalizeSingleByteRange(rangeHeader)) - if err != nil { - return nil, err - } - return &ResourceStream{Resource: resource, Body: stream.Body, StatusCode: stream.StatusCode, ContentLength: stream.ContentLength, ContentRange: stream.ContentRange, AcceptRanges: stream.AcceptRanges}, nil -} - -func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) { - return s.storeResourceWithWriter(userID, kind, fileName, mimeType, size, width, height, durationMs, body, uploadKey, forceLocal, s.storeResourceObject) -} - -func (s *Service) storeResourceWithWriter(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool, writeObject func(*model.Resource, string, io.Reader) (string, error)) (*model.Resource, bool, error) { - if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil { - return nil, false, err - } else if existing != nil { - if existing.Status == model.ResourceStatusReady { - return existing, false, nil - } - return nil, false, resourceUploadInProgress() - } - now := time.Now() - // Live2D is reserved for the administrator's validated local import path. - if !(forceLocal && kind == "live2d") { - kind = normalizeResourceKind(kind, mimeType) - } - var setting ossSettingValue - var storageSettingID string - var useOSS bool - var err error - if !forceLocal { - setting, storageSettingID, useOSS, err = s.activeResourceOSSSetting(userID) - if err != nil { - return nil, false, err - } - } - provider := "local" - objectKey := localObjectKey(userID, kind, fileName, mimeType, now) - resource := model.Resource{ID: newID(), UserID: userID, Kind: kind, Status: model.ResourceStatusPending, Provider: provider, ObjectKey: objectKey, MimeType: mimeType, Size: size, Width: width, Height: height, DurationMs: durationMs, UploadKey: uploadKey, CreatedAt: now, UpdatedAt: now} - if useOSS { - provider = setting.Provider - objectKey = ossObjectKey(setting, userID, kind, fileName, mimeType, now) - resource.Provider = provider - resource.Endpoint = setting.Endpoint - resource.Bucket = setting.Bucket - resource.StorageSettingID = storageSettingID - resource.ObjectKey = objectKey - } - if err := s.repo.CreateResource(&resource); err != nil { - if existing, lookupErr := s.resourceForUploadKey(userID, uploadKey); lookupErr == nil && existing != nil { - if existing.Status == model.ResourceStatusReady { - return existing, false, nil - } - return nil, false, resourceUploadInProgress() - } - return nil, false, err - } - var etag string - etag, err = writeObject(&resource, fileName, body) - resource.UpdatedAt = time.Now() - if err != nil { - resource.Status = model.ResourceStatusFailed - resource.Error = err.Error() - if saveErr := s.repo.SaveResource(&resource); saveErr != nil { - return nil, true, errors.Join(err, fmt.Errorf("记录资源失败状态失败:%w", saveErr)) - } - return nil, true, err - } - resource.Status = model.ResourceStatusReady - resource.ETag = etag - if err := s.repo.SaveResource(&resource); err != nil { - cleanupErr := s.deleteStoredResourceObject(userID, &resource) - if cleanupErr == nil { - if deleteErr := s.repo.DeleteResource(userID, resource.ID); deleteErr != nil { - return nil, true, errors.Join(err, fmt.Errorf("清理资源记录失败:%w", deleteErr)) - } - return nil, true, fmt.Errorf("保存资源就绪状态失败:%w", err) - } - - resource.Status = model.ResourceStatusFailed - resource.Error = fmt.Sprintf("保存资源就绪状态失败,物理对象清理失败:%v", cleanupErr) - statusErr := s.repo.SaveResource(&resource) - if statusErr != nil { - return nil, true, errors.Join(err, cleanupErr, fmt.Errorf("记录资源失败状态失败:%w", statusErr)) - } - return nil, true, errors.Join(err, fmt.Errorf("清理已上传资源对象失败:%w", cleanupErr)) - } - s.recordActivity(userID, "resource", 1) - s.maybeStartPlaybackTranscode(&resource) - return &resource, true, nil -} - -func writeLocalResourceObject(filePath string, body io.Reader) error { - if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { - return err - } - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) - if err != nil { - return err - } - _, copyErr := io.Copy(file, body) - closeErr := file.Close() - if copyErr != nil { - return copyErr - } - return closeErr -} - -// storeResourceObject writes to the configured origin. When object storage is -// enabled, a failed origin write must fail the resource instead of silently -// changing its storage location to local disk. -func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) { - if resource == nil { - return "", errors.New("资源不存在") - } - if resource.Provider == "local" { - return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body) - } - setting, settingErr := s.ossSettingForResource(resource.UserID, resource) - if settingErr != nil { - return "", settingErr - } - etag, err := putOSSObject(s.storageSettingWithRuntimePolicy(setting), resource.ObjectKey, resource.MimeType, resource.Size, body) - if err != nil { - return "", err - } - return etag, nil -} - -func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) { - if resource == nil { - return nil, errors.New("资源不存在") - } - if resource.Status == model.ResourceStatusReady { - return resource, nil - } - if resource.Status != model.ResourceStatusFailed { - return nil, resourceUploadInProgress() - } - kind = normalizeResourceKind(kind, mimeType) - if resource.Size != size || resource.Kind != kind || (resource.MimeType != "" && mimeType != "" && resource.MimeType != mimeType) { - return nil, NewAppError(http.StatusConflict, "上传幂等标识已用于其他文件") - } - claimed, err := s.repo.ClaimFailedResourceUpload(userID, resource.ID) - if err != nil { - return nil, err - } - if !claimed { - latest, latestErr := s.repo.ResourceForUser(userID, resource.ID) - if latestErr == nil && latest.Status == model.ResourceStatusReady { - return latest, nil - } - return nil, resourceUploadInProgress() - } - resource.Status = model.ResourceStatusPending - resource.Error = "" - resource.UpdatedAt = time.Now() - day, err := s.reserveRetryUploadQuota(userID, size) - if err != nil { - resource.Status = model.ResourceStatusFailed - resource.Error = err.Error() - resource.UpdatedAt = time.Now() - if saveErr := s.repo.SaveResource(resource); saveErr != nil { - return nil, errors.Join(err, fmt.Errorf("恢复资源重试失败状态失败:%w", saveErr)) - } - return nil, err - } - var etag string - etag, err = s.storeResourceObject(resource, "", body) - resource.UpdatedAt = time.Now() - if err != nil { - s.releaseRetryUploadQuota(userID, day, size) - resource.Status = model.ResourceStatusFailed - resource.Error = err.Error() - if saveErr := s.repo.SaveResource(resource); saveErr != nil { - return nil, errors.Join(err, fmt.Errorf("记录资源重试失败状态失败:%w", saveErr)) - } - return nil, err - } - resource.Status = model.ResourceStatusReady - resource.ETag = etag - if err := s.repo.SaveResource(resource); err != nil { - s.releaseRetryUploadQuota(userID, day, size) - resource.Status = model.ResourceStatusFailed - resource.Error = "保存资源重试就绪状态失败" - if saveErr := s.repo.SaveResource(resource); saveErr != nil { - return nil, errors.Join(err, fmt.Errorf("记录资源重试失败状态失败:%w", saveErr)) - } - return nil, fmt.Errorf("保存资源重试就绪状态失败:%w", err) - } - s.recordActivity(userID, "resource", 1) - return resource, nil -} - -func localObjectKey(userID string, kind string, fileName string, mimeType string, now time.Time) string { - ext := resourceFileExtension(fileName, mimeType, kind) - return path.Join("users", safeObjectSegment(userID), kind, now.Format("2006/01/02"), newID()+ext) -} - -func (s *Service) persistGeneratedMediaResult(userID string, result map[string]interface{}) (map[string]interface{}, error) { - return s.persistGeneratedMediaResultMode(userID, result, false, true) -} - -func (s *Service) persistLegacyGeneratedMediaResult(userID string, result map[string]interface{}) (map[string]interface{}, error) { - return s.persistGeneratedMediaResultMode(userID, result, true, false) -} - -func (s *Service) persistGeneratedMediaResultMode(userID string, result map[string]interface{}, skipInvalidDataURL bool, enforceQuota bool) (map[string]interface{}, error) { - if result == nil { - return map[string]interface{}{}, nil - } - encoded, err := json.Marshal(result) - if err != nil { - return nil, err - } - var normalized map[string]interface{} - if err := json.Unmarshal(encoded, &normalized); err != nil { - return nil, err - } - value, err := s.persistGeneratedMediaValueMode(userID, normalized, skipInvalidDataURL, enforceQuota) - if err != nil { - return nil, err - } - return value.(map[string]interface{}), nil -} - -func (s *Service) persistGeneratedMediaValue(userID string, value interface{}) (interface{}, error) { - return s.persistGeneratedMediaValueMode(userID, value, false, true) -} - -func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{}, skipInvalidDataURL bool, enforceQuota bool) (interface{}, error) { - switch item := value.(type) { - case []interface{}: - for index, child := range item { - stored, err := s.persistGeneratedMediaValueMode(userID, child, skipInvalidDataURL, enforceQuota) - if err != nil { - return nil, err - } - item[index] = stored - } - return item, nil - case map[string]interface{}: - if raw := inlineMediaValue(item); raw != "" { - mimeType, data, err := s.decodeDataURL(raw) - if err != nil && !skipInvalidDataURL { - return nil, err - } - if err == nil { - kind := normalizeResourceKind("", mimeType) - width, height := intValue(item["width"]), intValue(item["height"]) - if kind == "image" && (width <= 0 || height <= 0) { - width, height = imageDimensions(data) - } - quotaDay := "" - if enforceQuota { - quotaDay, err = s.reserveGeneratedResourceQuota(userID, int64(len(data))) - if err != nil { - return nil, err - } - } - resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil, false) - if err != nil { - if enforceQuota { - s.releaseUserUploadQuota(userID, quotaDay, int64(len(data))) - } - return nil, fmt.Errorf("生成内容写入资源存储失败:%w", err) - } - if enforceQuota { - s.commitUserUploadQuota(userID, int64(len(data))) - } - resourceURL := resourceFileURL(resource.ID) - for _, key := range []string{"dataUrl", "content", "url", "coverUrl"} { - if text, ok := item[key].(string); ok && (text == raw || strings.HasPrefix(text, "blob:")) { - item[key] = resourceURL - } - } - if _, ok := item["dataUrl"]; ok { - item["dataUrl"] = resourceURL - } - item["url"] = resourceURL - item["storageKey"] = "resource:" + resource.ID - item["resourceId"] = resource.ID - item["bytes"] = resource.Size - item["mimeType"] = resource.MimeType - item["width"] = resource.Width - item["height"] = resource.Height - } - } - for key, child := range item { - stored, err := s.persistGeneratedMediaValueMode(userID, child, skipInvalidDataURL, enforceQuota) - if err != nil { - return nil, err - } - item[key] = stored - } - return item, nil - default: - return value, nil - } -} - -func inlineMediaValue(item map[string]interface{}) string { - for _, key := range []string{"dataUrl", "content", "url", "coverUrl"} { - if text, ok := item[key].(string); ok && (strings.HasPrefix(text, "data:image/") || strings.HasPrefix(text, "data:video/") || strings.HasPrefix(text, "data:audio/")) { - return text - } - } - return "" -} - -func (s *Service) decodeDataURL(value string) (string, []byte, error) { - header, encoded, ok := strings.Cut(value, ",") - if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") { - return "", nil, fmt.Errorf("%w:格式无效", errInvalidGeneratedDataURL) - } - mimeType := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") - data, err := base64.StdEncoding.DecodeString(encoded) - if err != nil { - return "", nil, fmt.Errorf("%w:base64 解码失败:%v", errInvalidGeneratedDataURL, err) - } - policy, err := s.RuntimePolicy() - if err != nil { - return "", nil, err - } - if int64(len(data)) > megabytes(policy.Resource.GeneratedFileMB) { - return "", nil, fmt.Errorf("单个生成资源超过 %dMB", policy.Resource.GeneratedFileMB) - } - return mimeType, data, nil -} - -func intValue(value interface{}) int { - switch number := value.(type) { - case float64: - return int(number) - case int: - return number - case int64: - return int(number) - default: - return 0 - } -} - -type remoteResourcePayload struct { - url string - endpoint string - fileName string - mimeType string - data []byte -} - -func downloadRemoteResource(rawURL string, maxBytes int64) (remoteResourcePayload, error) { - parsed, err := validateRemoteResourceURL(rawURL) - if err != nil { - return remoteResourcePayload{}, err - } - client := OutboundHTTPClient(90 * time.Second) - req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) - if err != nil { - return remoteResourcePayload{}, err - } - resp, err := client.Do(req) - if err != nil { - return remoteResourcePayload{}, err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return remoteResourcePayload{}, fmt.Errorf("远程资源下载失败:%s", resp.Status) - } - data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) - if err != nil { - return remoteResourcePayload{}, err - } - if int64(len(data)) >= maxBytes { - return remoteResourcePayload{}, BadAuthRequest(fmt.Sprintf("远程资源必须小于 %s", formatStorageLimit(maxBytes))) - } - mimeType := strings.TrimSpace(resp.Header.Get("Content-Type")) - if idx := strings.Index(mimeType, ";"); idx >= 0 { - mimeType = strings.TrimSpace(mimeType[:idx]) - } - if mimeType == "" || mimeType == "application/octet-stream" { - mimeType = http.DetectContentType(data) - } - fileName := path.Base(parsed.Path) - if fileName == "" || fileName == "." || !strings.Contains(fileName, ".") { - fileName = "resource." + extensionFromMimeType(mimeType) - } - return remoteResourcePayload{url: parsed.String(), endpoint: parsed.Host, fileName: fileName, mimeType: mimeType, data: data}, nil -} - -func openRemoteResource(rawURL string) (io.ReadCloser, error) { - parsed, err := validateRemoteResourceURL(rawURL) - if err != nil { - return nil, err - } - client := OutboundHTTPClient(90 * time.Second) - resp, err := client.Get(parsed.String()) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - defer resp.Body.Close() - return nil, fmt.Errorf("远程资源读取失败:%s", resp.Status) - } - return resp.Body, nil -} - -func validateRemoteResourceURL(rawURL string) (*url.URL, error) { - return ValidateOutboundURL(rawURL) -} - -func extensionFromMimeType(mimeType string) string { - if strings.Contains(mimeType, "png") { - return "png" - } - if strings.Contains(mimeType, "jpeg") { - return "jpg" - } - if strings.Contains(mimeType, "webp") { - return "webp" - } - if strings.Contains(mimeType, "gif") { - return "gif" - } - if strings.Contains(mimeType, "mp4") { - return "mp4" - } - if strings.Contains(mimeType, "webm") { - return "webm" - } - if strings.Contains(mimeType, "mpeg") { - return "mp3" - } - if strings.Contains(mimeType, "wav") { - return "wav" - } - return "bin" -} - -func resourceFileExtension(fileName string, mimeType string, kind string) string { - if ext := strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))); ext != "" && ext != "." { - return ext - } - cleanMimeType := strings.TrimSpace(strings.Split(mimeType, ";")[0]) - if extensions, err := mime.ExtensionsByType(cleanMimeType); err == nil && len(extensions) > 0 { - return strings.ToLower(extensions[0]) - } - switch kind { - case "image": - return ".png" - case "video": - return ".mp4" - case "audio": - return ".mp3" - default: - return ".bin" - } -} - -func imageDimensions(data []byte) (int, int) { - config, _, err := image.DecodeConfig(bytes.NewReader(data)) - if err != nil { - return 0, 0 - } - return config.Width, config.Height -} - -func (s *Service) activeOSSSetting() (ossSettingValue, error) { - _, setting, err := s.readOSSSetting() - if err != nil { - return ossSettingValue{}, err - } - return validateActiveOSSSetting(setting, "管理员尚未启用 OSS", "平台 OSS 配置不完整,请联系管理员") -} - -func (s *Service) activeResourceOSSSetting(userID string) (ossSettingValue, string, bool, error) { - userSetting, value, err := s.readUserOSSSetting(userID) - if err != nil { - return ossSettingValue{}, "", false, err - } - _, systemValue, err := s.readOSSSetting() - if err != nil { - return ossSettingValue{}, "", false, err - } - userAllowed := value.Provider != s3Provider || systemValue.AllowUserS3 - if userSetting != nil && value.Enabled && userAllowed { - value, err = validateActiveOSSSetting(value, "用户 OSS 尚未启用", "你的 OSS 配置不完整") - return value, firstNonEmpty(value.StorageLocationID, userSetting.ID), true, err - } - if !systemValue.Enabled { - return ossSettingValue{}, "", false, nil - } - systemValue, err = validateActiveOSSSetting(systemValue, "管理员尚未启用 OSS", "平台 OSS 配置不完整,请联系管理员") - return systemValue, systemValue.StorageLocationID, true, err -} - -func (s *Service) ossSettingForResource(userID string, resource *model.Resource) (ossSettingValue, error) { - var setting ossSettingValue - var err error - if resource.StorageSettingID != "" { - _, setting, err = s.storageLocationValue(resource.StorageSettingID) - if errors.Is(err, gorm.ErrRecordNotFound) { - _, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID) - } - if err == nil { - _, current, currentErr := s.readUserOSSSetting(userID) - if currentErr != nil { - return ossSettingValue{}, currentErr - } - // 密钥固定在资源绑定的历史版本;只有存储位置完全一致时,才允许沿用当前 CDN。 - if resourceStorageMatches(current, resource) { - setting.CDNBaseURL = current.CDNBaseURL - // CDN 的鉴权方式和回退策略属于分发配置,而不是资源创建时的 - // 凭据。存储位置未变时沿用当前策略,避免历史资源因管理员 - // 刚补齐 CDN 鉴权配置而继续回源。 - setting.Delivery = current.Delivery - } - } - } else { - // 早期资源没有 StorageSettingID,但资源本身仍记录了 provider/endpoint/bucket。 - // 先从用户 OSS 历史版本中按存储位置反查,不能把当前七牛配置猜给历史阿里云对象。 - setting, err = s.userOSSSettingForResource(userID, resource) - if errors.Is(err, gorm.ErrRecordNotFound) { - _, setting, err = s.readOSSSetting() - } - } - if err != nil { - return ossSettingValue{}, err - } - resourceProvider := strings.ToLower(strings.TrimSpace(resource.Provider)) - resourceMatchesSetting := resourceStorageMatches(setting, resource) - setting, err = ossSettingForProvider(setting, firstNonEmpty(resource.Provider, setting.Provider)) - if err != nil { - return ossSettingValue{}, err - } - setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) - setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket) - // CDN 是具体存储位置的出口,不得在 provider/endpoint/bucket 不匹配时继续沿用, - // 否则切换到七牛后会把历史阿里云 objectKey 拼成七牛域名。 - if !resourceMatchesSetting || (resourceProvider != "" && resourceProvider != setting.Provider) { - setting.CDNBaseURL = "" - } - if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { - return ossSettingValue{}, errors.New("对象存储访问密钥不可用") - } - return setting, nil -} - -func (s *Service) userOSSSettingForResource(userID string, resource *model.Resource) (ossSettingValue, error) { - settings, err := s.repo.UserOSSSettingsForUser(userID) - if err != nil { - return ossSettingValue{}, err - } - for index := range settings { - value, valueErr := s.userOSSSettingValue(&settings[index]) - if valueErr != nil { - return ossSettingValue{}, valueErr - } - if resourceStorageMatches(value, resource) { - return value, nil - } - } - return ossSettingValue{}, gorm.ErrRecordNotFound -} - -func resourceStorageMatches(setting ossSettingValue, resource *model.Resource) bool { - if resource == nil { - return false - } - setting = normalizeOSSSetting(setting) - return setting.Provider == strings.ToLower(strings.TrimSpace(resource.Provider)) && - setting.Endpoint == strings.TrimRight(strings.TrimSpace(resource.Endpoint), "/") && - setting.Bucket == strings.TrimSpace(resource.Bucket) -} - -func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) { - setting = normalizeOSSSetting(setting) - provider = strings.ToLower(strings.TrimSpace(provider)) - if provider == "" || provider == setting.Provider { - return setting, nil - } - credentials, ok := setting.ArchivedCredentials[provider] - if !ok || credentials.AccessKeyID == "" || credentials.AccessKeySecret == "" { - return ossSettingValue{}, errors.New("历史对象存储访问密钥不可用") - } - setting.Provider = provider - setting.AccessKeyID = credentials.AccessKeyID - setting.AccessKeySecret = credentials.AccessKeySecret - return setting, nil -} - -func validateActiveOSSSetting(setting ossSettingValue, disabledMessage string, incompleteMessage string) (ossSettingValue, error) { - setting = normalizeOSSSetting(setting) - if !setting.Enabled { - return ossSettingValue{}, BadAuthRequest(disabledMessage) - } - if setting.Provider != aliyunOSSProvider && setting.Provider != tencentCOSProvider && setting.Provider != qiniuKodoProvider && setting.Provider != s3Provider { - return ossSettingValue{}, BadAuthRequest("仅支持阿里云 OSS、腾讯云 COS、七牛云 Kodo 和通用 S3") - } - if setting.Bucket == "" || setting.Endpoint == "" || setting.AccessKeyID == "" || setting.AccessKeySecret == "" { - return ossSettingValue{}, BadAuthRequest(incompleteMessage) - } - return setting, nil -} - func normalizeResourceKind(kind string, mimeType string) string { kind = strings.ToLower(strings.TrimSpace(kind)) switch kind { diff --git a/backend/internal/app/resource_batch_delete_test.go b/backend/internal/app/resource_batch_delete_test.go index 7f2e214fb..364c68e1c 100644 --- a/backend/internal/app/resource_batch_delete_test.go +++ b/backend/internal/app/resource_batch_delete_test.go @@ -43,27 +43,8 @@ func TestPurgeAssetsBatchSharedResourcesAndHistory(t *testing.T) { } } - // Active task, canvas and history references must block deletion. - if err := svc.PurgeUserAssets("user-1", []string{"first", "second"}); err == nil { - t.Fatal("expected referenced assets to remain protected") - } - assertCount(&model.Asset{}, 3) - assertCount(&model.Resource{}, 4) - assertCount(&model.ResourceDeletionJob{}, 0) - assertCount(&model.CanvasSnapshotResource{}, 1) - assertCount(&model.AssetVersion{}, 2) - assertCount(&model.AssetRepresentation{}, 2) - - // Once references are released, a late database error must still roll back. - if err := db.Model(&model.Task{}).Where("id = ?", "batch-task").Update("input_json", `{}`).Error; err != nil { - t.Fatal(err) - } - if err := db.Model(&model.CanvasProject{}).Where("id = ?", "batch-canvas").Update("payload_json", `{}`).Error; err != nil { - t.Fatal(err) - } - if err := db.Delete(&model.CanvasSnapshotResource{}, "snapshot_id = ?", "batch-snapshot").Error; err != nil { - t.Fatal(err) - } + // 彻底删除不受任务、画布与画布历史引用拦截(产品约定); + // 但事务后段失败时,素材、版本、表现、历史索引与 Outbox 必须整批回滚。 if err := db.Exec("CREATE TRIGGER fail_batch_resource_delete BEFORE DELETE ON resources BEGIN SELECT RAISE(ABORT, 'forced failure'); END;").Error; err != nil { t.Fatal(err) } @@ -73,7 +54,7 @@ func TestPurgeAssetsBatchSharedResourcesAndHistory(t *testing.T) { assertCount(&model.Asset{}, 3) assertCount(&model.Resource{}, 4) assertCount(&model.ResourceDeletionJob{}, 0) - assertCount(&model.CanvasSnapshotResource{}, 0) + assertCount(&model.CanvasSnapshotResource{}, 1) assertCount(&model.AssetVersion{}, 2) assertCount(&model.AssetRepresentation{}, 2) if err := db.Exec("DROP TRIGGER fail_batch_resource_delete").Error; err != nil { diff --git a/backend/internal/app/resource_delete.go b/backend/internal/app/resource_delete.go index a924007ba..d168e5a35 100644 --- a/backend/internal/app/resource_delete.go +++ b/backend/internal/app/resource_delete.go @@ -43,10 +43,18 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur if len(assetRecords) != len(assetIDs) { return NotFound("素材不存在或无权删除") } + // 删除语义(产品约定,勿收紧): + // - purge=true 是用户在素材库确认后的「彻底删除」(单个 DELETE、批量删除、清空回收站), + // 不因画布、任务、项目或画布历史快照仍引用而拒绝;旧引用随之失效,画布保存时由 + // repairMissingResources 修复断链。 + // - purge=false 是内部普通删除:回收站(archived)素材同样直接删除,其余素材保留引用保护。 + // - 无论哪种,被其他素材复用的资源与物理对象都保留,物理删除一律经 Outbox 异步执行。 + deleteReferencedResources := purge if !purge { if len(assetRecords) != 1 { return BadAuthRequest("普通删除仅支持单个素材") } + deleteReferencedResources = assetRecords[0].Status == model.AssetVersionStatusArchived } versions, representations, err := s.repo.AssetsResourceRecords(assetIDs) if err != nil { @@ -86,17 +94,26 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur } usages := make([]resourceUsage, 0) - for _, assetID := range assetIDs { - assetReferences, referenceErr := s.repo.AssetBusinessReferences(userID, assetID) - if referenceErr != nil { - return referenceErr - } - for _, reference := range assetReferences { - usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + if !deleteReferencedResources { + for _, assetID := range assetIDs { + assetReferences, referenceErr := s.repo.AssetBusinessReferences(userID, assetID) + if referenceErr != nil { + return referenceErr + } + for _, reference := range assetReferences { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } } } if len(ownedIDs) > 0 { - snapshot, snapshotErr := s.repo.ResourceReferenceSnapshotExcludingAssets(userID, assetIDs, ownedIDs) + var snapshot repository.ResourceReferenceSnapshot + var snapshotErr error + if deleteReferencedResources { + // 彻底删除只需要知道哪些资源仍被「其他素材」复用(这些资源要保留),不扫描任务和画布。 + snapshot, snapshotErr = s.repo.OtherAssetResourceReferences(userID, assetIDs) + } else { + snapshot, snapshotErr = s.repo.ResourceReferenceSnapshotExcludingAssets(userID, assetIDs, ownedIDs) + } if snapshotErr != nil { return snapshotErr } @@ -107,7 +124,9 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur sharedAssetResourceIDs[reference.ResourceID] = struct{}{} continue } - usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + if !deleteReferencedResources { + usages = append(usages, resourceUsage{Kind: reference.Kind, ID: reference.ID, Title: reference.Title}) + } } } for _, document := range snapshot.Documents { @@ -133,7 +152,9 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur } continue } - usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + if !deleteReferencedResources { + usages = append(usages, resourceUsage{Kind: document.Kind, ID: document.ID, Title: document.Title}) + } } } if len(sharedAssetResourceIDs) > 0 { @@ -166,7 +187,7 @@ func (s *Service) deleteUserAssetsWithResources(userID string, ids []string, pur deletionJobs := resourceDeletionJobs(userID, physicalObjects) // 业务记录和 Outbox 必须在同一事务提交。事务失败时物理文件完全不动; // 提交成功后由幂等 worker 清理,进程退出或对象存储暂时失败都可继续重试。 - if err := s.repo.DeleteAssetsAndResources(userID, assetIDs, ownedIDs, deletionJobs, false); err != nil { + if err := s.repo.DeleteAssetsAndResources(userID, assetIDs, ownedIDs, deletionJobs, deleteReferencedResources); err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return NotFound("素材不存在或无权删除") } diff --git a/backend/internal/app/resource_delete_test.go b/backend/internal/app/resource_delete_test.go index 33b1b3b8c..02e9f280b 100644 --- a/backend/internal/app/resource_delete_test.go +++ b/backend/internal/app/resource_delete_test.go @@ -298,7 +298,7 @@ func TestDeleteGeneratedAssetTaskReferences(t *testing.T) { } } -func TestPurgeConfirmedAssetPreservesTaskReferences(t *testing.T) { +func TestPurgeConfirmedAssetIgnoresTaskReferences(t *testing.T) { svc, db, _ := newResourceDeletionTestService(t) payload := `{"url":"/api/resources/purge-confirmed-resource/file"}` resource := model.Resource{ @@ -319,16 +319,17 @@ func TestPurgeConfirmedAssetPreservesTaskReferences(t *testing.T) { } } - if err := svc.PurgeUserAsset("user-1", asset.ID); err == nil || !strings.Contains(err.Error(), "任务") { - t.Fatalf("PurgeUserAsset() ignored active task: %v", err) + // 用户确认的彻底删除不受任务引用拦截;任务记录本身保留,其中的媒体链接随之失效。 + if err := svc.PurgeUserAsset("user-1", asset.ID); err != nil { + t.Fatalf("PurgeUserAsset() error = %v", err) } for _, check := range []struct { model any want int64 }{ - {&model.Asset{}, 1}, - {&model.Resource{}, 1}, - {&model.ResourceDeletionJob{}, 0}, + {&model.Asset{}, 0}, + {&model.Resource{}, 0}, + {&model.ResourceDeletionJob{}, 1}, {&model.Task{}, 1}, } { var count int64 @@ -373,7 +374,7 @@ func TestResourceDeletionWorkerRemovesObjectAndCompletesOutbox(t *testing.T) { } } -func TestExpiredArchivedAssetCleanupPreservesReferencedAsset(t *testing.T) { +func TestExpiredArchivedAssetCleanupDeletesReferencedAssetAndUsesDeletionOutbox(t *testing.T) { svc, db, _ := newResourceDeletionTestService(t) old := time.Now().Add(-45 * 24 * time.Hour) resource := model.Resource{ @@ -406,8 +407,8 @@ func TestExpiredArchivedAssetCleanupPreservesReferencedAsset(t *testing.T) { if err := db.Model(&model.ResourceDeletionJob{}).Where("resource_id = ?", resource.ID).Count(&jobCount).Error; err != nil { t.Fatal(err) } - if assetCount != 1 || resourceCount != 1 || jobCount != 0 { - t.Fatalf("referenced archived asset was removed: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) + if assetCount != 0 || resourceCount != 0 || jobCount != 1 { + t.Fatalf("referenced archived asset was not force deleted through outbox: asset=%d resource=%d jobs=%d", assetCount, resourceCount, jobCount) } } diff --git a/backend/internal/app/resource_generated.go b/backend/internal/app/resource_generated.go new file mode 100644 index 000000000..db3e082d9 --- /dev/null +++ b/backend/internal/app/resource_generated.go @@ -0,0 +1,294 @@ +// 生成结果(data URL / 远程地址)的持久化与远程下载。 +// +// 远程下载经过出站地址校验(validateRemoteResourceURL),防止 SSRF。 + +package app + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "image" + "io" + "mime" + "net/http" + "net/url" + "path" + "path/filepath" + "strings" + "time" +) + +func localObjectKey(userID string, kind string, fileName string, mimeType string, now time.Time) string { + ext := resourceFileExtension(fileName, mimeType, kind) + return path.Join("users", safeObjectSegment(userID), kind, now.Format("2006/01/02"), newID()+ext) +} + +func (s *Service) persistGeneratedMediaResult(userID string, result map[string]interface{}) (map[string]interface{}, error) { + return s.persistGeneratedMediaResultMode(userID, result, false, true) +} + +func (s *Service) persistLegacyGeneratedMediaResult(userID string, result map[string]interface{}) (map[string]interface{}, error) { + return s.persistGeneratedMediaResultMode(userID, result, true, false) +} + +func (s *Service) persistGeneratedMediaResultMode(userID string, result map[string]interface{}, skipInvalidDataURL bool, enforceQuota bool) (map[string]interface{}, error) { + if result == nil { + return map[string]interface{}{}, nil + } + encoded, err := json.Marshal(result) + if err != nil { + return nil, err + } + var normalized map[string]interface{} + if err := json.Unmarshal(encoded, &normalized); err != nil { + return nil, err + } + value, err := s.persistGeneratedMediaValueMode(userID, normalized, skipInvalidDataURL, enforceQuota) + if err != nil { + return nil, err + } + return value.(map[string]interface{}), nil +} + +func (s *Service) persistGeneratedMediaValue(userID string, value interface{}) (interface{}, error) { + return s.persistGeneratedMediaValueMode(userID, value, false, true) +} + +func (s *Service) persistGeneratedMediaValueMode(userID string, value interface{}, skipInvalidDataURL bool, enforceQuota bool) (interface{}, error) { + switch item := value.(type) { + case []interface{}: + for index, child := range item { + stored, err := s.persistGeneratedMediaValueMode(userID, child, skipInvalidDataURL, enforceQuota) + if err != nil { + return nil, err + } + item[index] = stored + } + return item, nil + case map[string]interface{}: + if raw := inlineMediaValue(item); raw != "" { + mimeType, data, err := s.decodeDataURL(raw) + if err != nil && !skipInvalidDataURL { + return nil, err + } + if err == nil { + kind := normalizeResourceKind("", mimeType) + width, height := intValue(item["width"]), intValue(item["height"]) + if kind == "image" && (width <= 0 || height <= 0) { + width, height = imageDimensions(data) + } + quotaDay := "" + if enforceQuota { + quotaDay, err = s.reserveGeneratedResourceQuota(userID, int64(len(data))) + if err != nil { + return nil, err + } + } + resource, _, err := s.storeResource(userID, kind, "generated."+extensionFromMimeType(mimeType), mimeType, int64(len(data)), width, height, int64(intValue(item["durationMs"])), bytes.NewReader(data), nil, false) + if err != nil { + if enforceQuota { + s.releaseUserUploadQuota(userID, quotaDay, int64(len(data))) + } + return nil, fmt.Errorf("生成内容写入资源存储失败:%w", err) + } + if enforceQuota { + s.commitUserUploadQuota(userID, int64(len(data))) + } + resourceURL := resourceFileURL(resource.ID) + for _, key := range []string{"dataUrl", "content", "url", "coverUrl"} { + if text, ok := item[key].(string); ok && (text == raw || strings.HasPrefix(text, "blob:")) { + item[key] = resourceURL + } + } + if _, ok := item["dataUrl"]; ok { + item["dataUrl"] = resourceURL + } + item["url"] = resourceURL + item["storageKey"] = "resource:" + resource.ID + item["resourceId"] = resource.ID + item["bytes"] = resource.Size + item["mimeType"] = resource.MimeType + item["width"] = resource.Width + item["height"] = resource.Height + } + } + for key, child := range item { + stored, err := s.persistGeneratedMediaValueMode(userID, child, skipInvalidDataURL, enforceQuota) + if err != nil { + return nil, err + } + item[key] = stored + } + return item, nil + default: + return value, nil + } +} + +func inlineMediaValue(item map[string]interface{}) string { + for _, key := range []string{"dataUrl", "content", "url", "coverUrl"} { + if text, ok := item[key].(string); ok && (strings.HasPrefix(text, "data:image/") || strings.HasPrefix(text, "data:video/") || strings.HasPrefix(text, "data:audio/")) { + return text + } + } + return "" +} + +func (s *Service) decodeDataURL(value string) (string, []byte, error) { + header, encoded, ok := strings.Cut(value, ",") + if !ok || !strings.HasPrefix(header, "data:") || !strings.HasSuffix(strings.ToLower(header), ";base64") { + return "", nil, fmt.Errorf("%w:格式无效", errInvalidGeneratedDataURL) + } + mimeType := strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64") + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", nil, fmt.Errorf("%w:base64 解码失败:%v", errInvalidGeneratedDataURL, err) + } + policy, err := s.RuntimePolicy() + if err != nil { + return "", nil, err + } + if int64(len(data)) > megabytes(policy.Resource.GeneratedFileMB) { + return "", nil, fmt.Errorf("单个生成资源超过 %dMB", policy.Resource.GeneratedFileMB) + } + return mimeType, data, nil +} + +func intValue(value interface{}) int { + switch number := value.(type) { + case float64: + return int(number) + case int: + return number + case int64: + return int(number) + default: + return 0 + } +} + +type remoteResourcePayload struct { + url string + endpoint string + fileName string + mimeType string + data []byte +} + +func downloadRemoteResource(rawURL string, maxBytes int64) (remoteResourcePayload, error) { + parsed, err := validateRemoteResourceURL(rawURL) + if err != nil { + return remoteResourcePayload{}, err + } + client := OutboundHTTPClient(90 * time.Second) + req, err := http.NewRequest(http.MethodGet, parsed.String(), nil) + if err != nil { + return remoteResourcePayload{}, err + } + resp, err := client.Do(req) + if err != nil { + return remoteResourcePayload{}, err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return remoteResourcePayload{}, fmt.Errorf("远程资源下载失败:%s", resp.Status) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes)) + if err != nil { + return remoteResourcePayload{}, err + } + if int64(len(data)) >= maxBytes { + return remoteResourcePayload{}, BadAuthRequest(fmt.Sprintf("远程资源必须小于 %s", formatStorageLimit(maxBytes))) + } + mimeType := strings.TrimSpace(resp.Header.Get("Content-Type")) + if idx := strings.Index(mimeType, ";"); idx >= 0 { + mimeType = strings.TrimSpace(mimeType[:idx]) + } + if mimeType == "" || mimeType == "application/octet-stream" { + mimeType = http.DetectContentType(data) + } + fileName := path.Base(parsed.Path) + if fileName == "" || fileName == "." || !strings.Contains(fileName, ".") { + fileName = "resource." + extensionFromMimeType(mimeType) + } + return remoteResourcePayload{url: parsed.String(), endpoint: parsed.Host, fileName: fileName, mimeType: mimeType, data: data}, nil +} + +func openRemoteResource(rawURL string) (io.ReadCloser, error) { + parsed, err := validateRemoteResourceURL(rawURL) + if err != nil { + return nil, err + } + client := OutboundHTTPClient(90 * time.Second) + resp, err := client.Get(parsed.String()) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + defer resp.Body.Close() + return nil, fmt.Errorf("远程资源读取失败:%s", resp.Status) + } + return resp.Body, nil +} + +func validateRemoteResourceURL(rawURL string) (*url.URL, error) { + return ValidateOutboundURL(rawURL) +} + +func extensionFromMimeType(mimeType string) string { + if strings.Contains(mimeType, "png") { + return "png" + } + if strings.Contains(mimeType, "jpeg") { + return "jpg" + } + if strings.Contains(mimeType, "webp") { + return "webp" + } + if strings.Contains(mimeType, "gif") { + return "gif" + } + if strings.Contains(mimeType, "mp4") { + return "mp4" + } + if strings.Contains(mimeType, "webm") { + return "webm" + } + if strings.Contains(mimeType, "mpeg") { + return "mp3" + } + if strings.Contains(mimeType, "wav") { + return "wav" + } + return "bin" +} + +func resourceFileExtension(fileName string, mimeType string, kind string) string { + if ext := strings.ToLower(filepath.Ext(strings.TrimSpace(fileName))); ext != "" && ext != "." { + return ext + } + cleanMimeType := strings.TrimSpace(strings.Split(mimeType, ";")[0]) + if extensions, err := mime.ExtensionsByType(cleanMimeType); err == nil && len(extensions) > 0 { + return strings.ToLower(extensions[0]) + } + switch kind { + case "image": + return ".png" + case "video": + return ".mp4" + case "audio": + return ".mp3" + default: + return ".bin" + } +} + +func imageDimensions(data []byte) (int, int) { + config, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil { + return 0, 0 + } + return config.Width, config.Height +} diff --git a/backend/internal/app/resource_oss.go b/backend/internal/app/resource_oss.go new file mode 100644 index 000000000..e7577c570 --- /dev/null +++ b/backend/internal/app/resource_oss.go @@ -0,0 +1,149 @@ +// 资源对应的对象存储配置解析:系统存储、用户自有存储与历史位置。 + +package app + +import ( + "errors" + "strings" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (s *Service) activeOSSSetting() (ossSettingValue, error) { + _, setting, err := s.readOSSSetting() + if err != nil { + return ossSettingValue{}, err + } + return validateActiveOSSSetting(setting, "管理员尚未启用 OSS", "平台 OSS 配置不完整,请联系管理员") +} + +func (s *Service) activeResourceOSSSetting(userID string) (ossSettingValue, string, bool, error) { + userSetting, value, err := s.readUserOSSSetting(userID) + if err != nil { + return ossSettingValue{}, "", false, err + } + _, systemValue, err := s.readOSSSetting() + if err != nil { + return ossSettingValue{}, "", false, err + } + userAllowed := value.Provider != s3Provider || systemValue.AllowUserS3 + if userSetting != nil && value.Enabled && userAllowed { + value, err = validateActiveOSSSetting(value, "用户 OSS 尚未启用", "你的 OSS 配置不完整") + return value, firstNonEmpty(value.StorageLocationID, userSetting.ID), true, err + } + if !systemValue.Enabled { + return ossSettingValue{}, "", false, nil + } + systemValue, err = validateActiveOSSSetting(systemValue, "管理员尚未启用 OSS", "平台 OSS 配置不完整,请联系管理员") + return systemValue, systemValue.StorageLocationID, true, err +} + +func (s *Service) ossSettingForResource(userID string, resource *model.Resource) (ossSettingValue, error) { + var setting ossSettingValue + var err error + if resource.StorageSettingID != "" { + _, setting, err = s.storageLocationValue(resource.StorageSettingID) + if errors.Is(err, gorm.ErrRecordNotFound) { + _, setting, err = s.readUserOSSSettingByID(userID, resource.StorageSettingID) + } + if err == nil { + _, current, currentErr := s.readUserOSSSetting(userID) + if currentErr != nil { + return ossSettingValue{}, currentErr + } + // 密钥固定在资源绑定的历史版本;只有存储位置完全一致时,才允许沿用当前 CDN。 + if resourceStorageMatches(current, resource) { + setting.CDNBaseURL = current.CDNBaseURL + // CDN 的鉴权方式和回退策略属于分发配置,而不是资源创建时的 + // 凭据。存储位置未变时沿用当前策略,避免历史资源因管理员 + // 刚补齐 CDN 鉴权配置而继续回源。 + setting.Delivery = current.Delivery + } + } + } else { + // 早期资源没有 StorageSettingID,但资源本身仍记录了 provider/endpoint/bucket。 + // 先从用户 OSS 历史版本中按存储位置反查,不能把当前七牛配置猜给历史阿里云对象。 + setting, err = s.userOSSSettingForResource(userID, resource) + if errors.Is(err, gorm.ErrRecordNotFound) { + _, setting, err = s.readOSSSetting() + } + } + if err != nil { + return ossSettingValue{}, err + } + resourceProvider := strings.ToLower(strings.TrimSpace(resource.Provider)) + resourceMatchesSetting := resourceStorageMatches(setting, resource) + setting, err = ossSettingForProvider(setting, firstNonEmpty(resource.Provider, setting.Provider)) + if err != nil { + return ossSettingValue{}, err + } + setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) + setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket) + // CDN 是具体存储位置的出口,不得在 provider/endpoint/bucket 不匹配时继续沿用, + // 否则切换到七牛后会把历史阿里云 objectKey 拼成七牛域名。 + if !resourceMatchesSetting || (resourceProvider != "" && resourceProvider != setting.Provider) { + setting.CDNBaseURL = "" + } + if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { + return ossSettingValue{}, errors.New("对象存储访问密钥不可用") + } + return setting, nil +} + +func (s *Service) userOSSSettingForResource(userID string, resource *model.Resource) (ossSettingValue, error) { + settings, err := s.repo.UserOSSSettingsForUser(userID) + if err != nil { + return ossSettingValue{}, err + } + for index := range settings { + value, valueErr := s.userOSSSettingValue(&settings[index]) + if valueErr != nil { + return ossSettingValue{}, valueErr + } + if resourceStorageMatches(value, resource) { + return value, nil + } + } + return ossSettingValue{}, gorm.ErrRecordNotFound +} + +func resourceStorageMatches(setting ossSettingValue, resource *model.Resource) bool { + if resource == nil { + return false + } + setting = normalizeOSSSetting(setting) + return setting.Provider == strings.ToLower(strings.TrimSpace(resource.Provider)) && + setting.Endpoint == strings.TrimRight(strings.TrimSpace(resource.Endpoint), "/") && + setting.Bucket == strings.TrimSpace(resource.Bucket) +} + +func ossSettingForProvider(setting ossSettingValue, provider string) (ossSettingValue, error) { + setting = normalizeOSSSetting(setting) + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" || provider == setting.Provider { + return setting, nil + } + credentials, ok := setting.ArchivedCredentials[provider] + if !ok || credentials.AccessKeyID == "" || credentials.AccessKeySecret == "" { + return ossSettingValue{}, errors.New("历史对象存储访问密钥不可用") + } + setting.Provider = provider + setting.AccessKeyID = credentials.AccessKeyID + setting.AccessKeySecret = credentials.AccessKeySecret + return setting, nil +} + +func validateActiveOSSSetting(setting ossSettingValue, disabledMessage string, incompleteMessage string) (ossSettingValue, error) { + setting = normalizeOSSSetting(setting) + if !setting.Enabled { + return ossSettingValue{}, BadAuthRequest(disabledMessage) + } + if setting.Provider != aliyunOSSProvider && setting.Provider != tencentCOSProvider && setting.Provider != qiniuKodoProvider && setting.Provider != s3Provider { + return ossSettingValue{}, BadAuthRequest("仅支持阿里云 OSS、腾讯云 COS、七牛云 Kodo 和通用 S3") + } + if setting.Bucket == "" || setting.Endpoint == "" || setting.AccessKeyID == "" || setting.AccessKeySecret == "" { + return ossSettingValue{}, BadAuthRequest(incompleteMessage) + } + return setting, nil +} diff --git a/backend/internal/app/resource_store.go b/backend/internal/app/resource_store.go new file mode 100644 index 000000000..7bdf25055 --- /dev/null +++ b/backend/internal/app/resource_store.go @@ -0,0 +1,433 @@ +// 资源写入:上传、URL 导入、生成结果落盘与失败重试。 +// +// 写入先落对象存储再登记 Resource 行;对象存储失败时明确报错,不静默降级到本地盘。 + +package app + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (s *Service) UploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + return s.uploadResource(userID, header, kind, width, height, durationMs, false, uploadIdentity...) +} + +// uploadLocalResource keeps small system-owned assets on the server even when +// the uploader or platform has enabled object storage. The resource still uses +// the normal database record and persistent data directory lifecycle. +func (s *Service) uploadLocalResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + return s.uploadResource(userID, header, kind, width, height, durationMs, true, uploadIdentity...) +} + +func (s *Service) uploadResource(userID string, header *multipart.FileHeader, kind string, width int, height int, durationMs int64, forceLocal bool, uploadIdentity ...string) (*model.Resource, error) { + if header == nil { + return nil, BadAuthRequest("请选择要上传的文件") + } + uploadKey := normalizedResourceUploadKey(uploadIdentity) + existing, err := s.resourceForUploadKey(userID, uploadKey) + if err != nil { + return nil, err + } + if existing != nil && existing.Status == model.ResourceStatusReady { + return existing, nil + } + if existing != nil && existing.Status == model.ResourceStatusPending { + return nil, resourceUploadInProgress() + } + file, err := header.Open() + if err != nil { + return nil, err + } + defer file.Close() + + mimeType := strings.TrimSpace(header.Header.Get("Content-Type")) + mimeType = detectUploadedMimeType(file, header.Filename, mimeType) + if existing != nil { + return s.retryStoredResource(userID, existing, kind, mimeType, header.Size, file) + } + day, err := s.reserveUserUploadQuota(userID, header.Size) + if err != nil { + return nil, err + } + resource, stored, err := s.storeResource(userID, kind, header.Filename, mimeType, header.Size, width, height, durationMs, file, uploadKey, forceLocal) + if err != nil { + s.releaseUserUploadQuota(userID, day, header.Size) + } else if stored { + s.commitUserUploadQuota(userID, header.Size) + } else { + s.releaseUserUploadQuota(userID, day, header.Size) + } + return resource, err +} + +// UploadResourceFile 接收已完整落盘的本地文件(分片上传合并后调用)。 +// 它与 UploadResource 共享资源幂等、媒体探测、配额和持久化语义,唯一差异是分片会话已在 handler 校验单文件上限, +// 因而此处不再重复该上限检查;uploadIdentity 用于跨请求重试时复用同一逻辑资源,避免重复对象。 +func (s *Service) UploadResourceFile(userID string, fileName string, size int64, kind string, width int, height int, durationMs int64, file io.ReadSeeker, uploadIdentity ...string) (*model.Resource, error) { + if file == nil || size <= 0 { + return nil, BadAuthRequest("请选择要上传的文件") + } + uploadKey := normalizedResourceUploadKey(uploadIdentity) + existing, err := s.resourceForUploadKey(userID, uploadKey) + if err != nil { + return nil, err + } + if existing != nil && existing.Status == model.ResourceStatusReady { + return existing, nil + } + if existing != nil && existing.Status == model.ResourceStatusPending { + return nil, resourceUploadInProgress() + } + mimeType := detectUploadedMimeType(file, fileName, "") + if existing != nil { + return s.retryStoredResource(userID, existing, kind, mimeType, size, file) + } + day, err := s.reserveChunkedUploadQuota(userID, size) + if err != nil { + return nil, err + } + resource, stored, err := s.storeResource(userID, kind, fileName, mimeType, size, width, height, durationMs, file, uploadKey, false) + if err != nil { + s.releaseUserUploadQuota(userID, day, size) + } else if stored { + s.commitUserUploadQuota(userID, size) + } else { + s.releaseUserUploadQuota(userID, day, size) + } + return resource, err +} + +func detectUploadedMimeType(file io.ReadSeeker, fileName string, declared string) string { + declared = strings.TrimSpace(strings.Split(declared, ";")[0]) + if declared != "" && declared != "application/octet-stream" { + return declared + } + buffer := make([]byte, 512) + read, _ := file.Read(buffer) + _, _ = file.Seek(0, io.SeekStart) + if detected := http.DetectContentType(buffer[:read]); detected != "" && detected != "application/octet-stream" { + return strings.TrimSpace(strings.Split(detected, ";")[0]) + } + if fromExtension := mime.TypeByExtension(filepath.Ext(fileName)); fromExtension != "" { + return strings.TrimSpace(strings.Split(fromExtension, ";")[0]) + } + return "application/octet-stream" +} + +func (s *Service) ImportResourceURL(userID string, rawURL string, kind string, width int, height int, durationMs int64, uploadIdentity ...string) (*model.Resource, error) { + uploadKey := normalizedResourceUploadKey(uploadIdentity) + existing, err := s.resourceForUploadKey(userID, uploadKey) + if err != nil { + return nil, err + } + if existing != nil && existing.Status == model.ResourceStatusReady { + return existing, nil + } + if existing != nil && existing.Status == model.ResourceStatusPending { + return nil, resourceUploadInProgress() + } + policy, err := s.RuntimePolicy() + if err != nil { + return nil, err + } + payload, err := downloadRemoteResource(rawURL, megabytes(policy.Resource.ResourceUploadMB)) + if err != nil { + return nil, err + } + kind = normalizeResourceKind(kind, payload.mimeType) + if kind == "image" && (width <= 0 || height <= 0) { + if decodedWidth, decodedHeight := imageDimensions(payload.data); decodedWidth > 0 && decodedHeight > 0 { + width = decodedWidth + height = decodedHeight + } + } + size := int64(len(payload.data)) + if existing != nil { + return s.retryStoredResource(userID, existing, kind, payload.mimeType, size, bytes.NewReader(payload.data)) + } + day, err := s.reserveUserUploadQuota(userID, size) + if err != nil { + return nil, err + } + resource, stored, err := s.storeResource(userID, kind, payload.fileName, payload.mimeType, size, width, height, durationMs, bytes.NewReader(payload.data), uploadKey, false) + if err != nil { + s.releaseUserUploadQuota(userID, day, size) + } else if stored { + s.commitUserUploadQuota(userID, size) + } else { + s.releaseUserUploadQuota(userID, day, size) + } + return resource, err +} + +func normalizedResourceUploadKey(values []string) *string { + if len(values) == 0 || strings.TrimSpace(values[0]) == "" { + return nil + } + digest := sha256.Sum256([]byte(strings.TrimSpace(values[0]))) + value := hex.EncodeToString(digest[:]) + return &value +} + +func (s *Service) resourceForUploadKey(userID string, uploadKey *string) (*model.Resource, error) { + if uploadKey == nil { + return nil, nil + } + resource, err := s.repo.ResourceByUploadKey(userID, *uploadKey) + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return resource, err +} + +func resourceUploadInProgress() *AppError { + err := NewAppError(http.StatusConflict, "相同素材正在上传,请稍后重试") + err.Retryable = true + return err +} + +func (s *Service) OpenResource(userID string, id string) (*model.Resource, io.ReadCloser, error) { + stream, err := s.OpenResourceRange(userID, id, "") + if err != nil { + return nil, nil, err + } + return stream.Resource, stream.Body, nil +} + +func (s *Service) OpenResourceRange(userID string, id string, rangeHeader string) (*ResourceStream, error) { + resource, err := s.repo.ResourceForUser(userID, id) + if err != nil { + return nil, err + } + return s.openResourceRange(userID, resource, rangeHeader) +} + +func (s *Service) openResourceRange(userID string, resource *model.Resource, rangeHeader string) (*ResourceStream, error) { + if resource.Status != model.ResourceStatusReady { + return nil, BadAuthRequest("资源尚未上传完成") + } + if resource.Provider == "local" { + body, err := os.Open(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey))) + if err != nil { + return nil, err + } + return &ResourceStream{Resource: resource, Body: body, StatusCode: http.StatusOK, ContentLength: resource.Size, AcceptRanges: "bytes"}, nil + } + setting, err := s.ossSettingForResource(userID, resource) + if err != nil { + return nil, err + } + if setting.AccessKeyID == "" || setting.AccessKeySecret == "" { + return nil, errors.New("对象存储访问密钥不可用") + } + setting.Provider = firstNonEmpty(resource.Provider, setting.Provider) + setting.Endpoint = firstNonEmpty(resource.Endpoint, setting.Endpoint) + setting.Bucket = firstNonEmpty(resource.Bucket, setting.Bucket) + setting = s.storageSettingWithRuntimePolicy(setting) + stream, err := getOriginOSSObjectRange(setting, resource.ObjectKey, normalizeSingleByteRange(rangeHeader)) + if err != nil { + return nil, err + } + return &ResourceStream{Resource: resource, Body: stream.Body, StatusCode: stream.StatusCode, ContentLength: stream.ContentLength, ContentRange: stream.ContentRange, AcceptRanges: stream.AcceptRanges}, nil +} + +func (s *Service) storeResource(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool) (*model.Resource, bool, error) { + return s.storeResourceWithWriter(userID, kind, fileName, mimeType, size, width, height, durationMs, body, uploadKey, forceLocal, s.storeResourceObject) +} + +func (s *Service) storeResourceWithWriter(userID string, kind string, fileName string, mimeType string, size int64, width int, height int, durationMs int64, body io.Reader, uploadKey *string, forceLocal bool, writeObject func(*model.Resource, string, io.Reader) (string, error)) (*model.Resource, bool, error) { + if existing, err := s.resourceForUploadKey(userID, uploadKey); err != nil { + return nil, false, err + } else if existing != nil { + if existing.Status == model.ResourceStatusReady { + return existing, false, nil + } + return nil, false, resourceUploadInProgress() + } + now := time.Now() + // Live2D is reserved for the administrator's validated local import path. + if !(forceLocal && kind == "live2d") { + kind = normalizeResourceKind(kind, mimeType) + } + var setting ossSettingValue + var storageSettingID string + var useOSS bool + var err error + if !forceLocal { + setting, storageSettingID, useOSS, err = s.activeResourceOSSSetting(userID) + if err != nil { + return nil, false, err + } + } + provider := "local" + objectKey := localObjectKey(userID, kind, fileName, mimeType, now) + resource := model.Resource{ID: newID(), UserID: userID, Kind: kind, Status: model.ResourceStatusPending, Provider: provider, ObjectKey: objectKey, MimeType: mimeType, Size: size, Width: width, Height: height, DurationMs: durationMs, UploadKey: uploadKey, CreatedAt: now, UpdatedAt: now} + if useOSS { + provider = setting.Provider + objectKey = ossObjectKey(setting, userID, kind, fileName, mimeType, now) + resource.Provider = provider + resource.Endpoint = setting.Endpoint + resource.Bucket = setting.Bucket + resource.StorageSettingID = storageSettingID + resource.ObjectKey = objectKey + } + if err := s.repo.CreateResource(&resource); err != nil { + if existing, lookupErr := s.resourceForUploadKey(userID, uploadKey); lookupErr == nil && existing != nil { + if existing.Status == model.ResourceStatusReady { + return existing, false, nil + } + return nil, false, resourceUploadInProgress() + } + return nil, false, err + } + var etag string + etag, err = writeObject(&resource, fileName, body) + resource.UpdatedAt = time.Now() + if err != nil { + resource.Status = model.ResourceStatusFailed + resource.Error = err.Error() + if saveErr := s.repo.SaveResource(&resource); saveErr != nil { + return nil, true, errors.Join(err, fmt.Errorf("记录资源失败状态失败:%w", saveErr)) + } + return nil, true, err + } + resource.Status = model.ResourceStatusReady + resource.ETag = etag + if err := s.repo.SaveResource(&resource); err != nil { + cleanupErr := s.deleteStoredResourceObject(userID, &resource) + if cleanupErr == nil { + if deleteErr := s.repo.DeleteResource(userID, resource.ID); deleteErr != nil { + return nil, true, errors.Join(err, fmt.Errorf("清理资源记录失败:%w", deleteErr)) + } + return nil, true, fmt.Errorf("保存资源就绪状态失败:%w", err) + } + + resource.Status = model.ResourceStatusFailed + resource.Error = fmt.Sprintf("保存资源就绪状态失败,物理对象清理失败:%v", cleanupErr) + statusErr := s.repo.SaveResource(&resource) + if statusErr != nil { + return nil, true, errors.Join(err, cleanupErr, fmt.Errorf("记录资源失败状态失败:%w", statusErr)) + } + return nil, true, errors.Join(err, fmt.Errorf("清理已上传资源对象失败:%w", cleanupErr)) + } + s.recordActivity(userID, "resource", 1) + s.maybeStartPlaybackTranscode(&resource) + return &resource, true, nil +} + +func writeLocalResourceObject(filePath string, body io.Reader) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { + return err + } + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640) + if err != nil { + return err + } + _, copyErr := io.Copy(file, body) + closeErr := file.Close() + if copyErr != nil { + return copyErr + } + return closeErr +} + +// storeResourceObject writes to the configured origin. When object storage is +// enabled, a failed origin write must fail the resource instead of silently +// changing its storage location to local disk. +func (s *Service) storeResourceObject(resource *model.Resource, fileName string, body io.Reader) (string, error) { + if resource == nil { + return "", errors.New("资源不存在") + } + if resource.Provider == "local" { + return "", writeLocalResourceObject(filepath.Join(s.dataDir, "resources", filepath.FromSlash(resource.ObjectKey)), body) + } + setting, settingErr := s.ossSettingForResource(resource.UserID, resource) + if settingErr != nil { + return "", settingErr + } + etag, err := putOSSObject(s.storageSettingWithRuntimePolicy(setting), resource.ObjectKey, resource.MimeType, resource.Size, body) + if err != nil { + return "", err + } + return etag, nil +} + +func (s *Service) retryStoredResource(userID string, resource *model.Resource, kind string, mimeType string, size int64, body io.Reader) (*model.Resource, error) { + if resource == nil { + return nil, errors.New("资源不存在") + } + if resource.Status == model.ResourceStatusReady { + return resource, nil + } + if resource.Status != model.ResourceStatusFailed { + return nil, resourceUploadInProgress() + } + kind = normalizeResourceKind(kind, mimeType) + if resource.Size != size || resource.Kind != kind || (resource.MimeType != "" && mimeType != "" && resource.MimeType != mimeType) { + return nil, NewAppError(http.StatusConflict, "上传幂等标识已用于其他文件") + } + claimed, err := s.repo.ClaimFailedResourceUpload(userID, resource.ID) + if err != nil { + return nil, err + } + if !claimed { + latest, latestErr := s.repo.ResourceForUser(userID, resource.ID) + if latestErr == nil && latest.Status == model.ResourceStatusReady { + return latest, nil + } + return nil, resourceUploadInProgress() + } + resource.Status = model.ResourceStatusPending + resource.Error = "" + resource.UpdatedAt = time.Now() + day, err := s.reserveRetryUploadQuota(userID, size) + if err != nil { + resource.Status = model.ResourceStatusFailed + resource.Error = err.Error() + resource.UpdatedAt = time.Now() + if saveErr := s.repo.SaveResource(resource); saveErr != nil { + return nil, errors.Join(err, fmt.Errorf("恢复资源重试失败状态失败:%w", saveErr)) + } + return nil, err + } + var etag string + etag, err = s.storeResourceObject(resource, "", body) + resource.UpdatedAt = time.Now() + if err != nil { + s.releaseRetryUploadQuota(userID, day, size) + resource.Status = model.ResourceStatusFailed + resource.Error = err.Error() + if saveErr := s.repo.SaveResource(resource); saveErr != nil { + return nil, errors.Join(err, fmt.Errorf("记录资源重试失败状态失败:%w", saveErr)) + } + return nil, err + } + resource.Status = model.ResourceStatusReady + resource.ETag = etag + if err := s.repo.SaveResource(resource); err != nil { + s.releaseRetryUploadQuota(userID, day, size) + resource.Status = model.ResourceStatusFailed + resource.Error = "保存资源重试就绪状态失败" + if saveErr := s.repo.SaveResource(resource); saveErr != nil { + return nil, errors.Join(err, fmt.Errorf("记录资源重试失败状态失败:%w", saveErr)) + } + return nil, fmt.Errorf("保存资源重试就绪状态失败:%w", err) + } + s.recordActivity(userID, "resource", 1) + return resource, nil +} diff --git a/backend/internal/app/workflow_provider.go b/backend/internal/app/workflow_provider.go index 1ecf94bf0..843729ab5 100644 --- a/backend/internal/app/workflow_provider.go +++ b/backend/internal/app/workflow_provider.go @@ -1,300 +1,28 @@ -package app +// RunningHub 的云端工作流协议适配集中在这里:提交流程、上传媒体、组装节点输入。 +// +// 同一协议的其余部分按职责拆分在: +// - workflow_provider_field.go 字段定义的兼容解析 +// - workflow_provider_values.go 画布参数到节点取值的换算 +// - workflow_provider_outputs.go 轮询、输出下载与错误码翻译 -// RunningHub 的云端工作流协议适配集中在这里。 +package app import ( "bytes" "context" - cryptorand "crypto/rand" "encoding/json" "errors" "fmt" - "math" - "math/big" "mime" "mime/multipart" "net/http" "net/textproto" - "net/url" - "sort" - "strconv" "strings" "time" "infinite-canvas/backend/internal/model" ) -// WorkflowField 是云端工作流字段描述。Value 与 FieldValue 兼容来源项目 -// 的两种命名;Source 可取 referenceImage/referenceVideo/referenceAudio/mask。 -type WorkflowField struct { - ID string `json:"id"` - NodeID string `json:"nodeId"` - ClassType string `json:"classType,omitempty"` - FieldName string `json:"fieldName"` - Value interface{} `json:"value,omitempty"` - FieldValue interface{} `json:"fieldValue,omitempty"` - FieldType string `json:"fieldType,omitempty"` - Label string `json:"label,omitempty"` - Role string `json:"role,omitempty"` - SafeToOverride *bool `json:"safeToOverride,omitempty"` - OptionsSource string `json:"optionsSource,omitempty"` - Options []interface{} `json:"options,omitempty"` - Min interface{} `json:"min,omitempty"` - Max interface{} `json:"max,omitempty"` - Step interface{} `json:"step,omitempty"` - RandomEnabled bool `json:"randomEnabled,omitempty"` - BindPrompt bool `json:"bindPrompt,omitempty"` - // 指针用于区分“未配置(默认启用)”和明确传入 false。 - Enabled *bool `json:"enabled,omitempty"` - Source string `json:"source,omitempty"` - SourceIndex int `json:"sourceIndex,omitempty"` - ImageOrder int `json:"imageOrder,omitempty"` - SourceFromUpstream bool `json:"sourceFromUpstream,omitempty"` - Required bool `json:"required,omitempty"` - // nil 兼容旧配置;true 表示来源由字段名推断,false 表示用户在映射面板明确选择。 - SourceAutomatic *bool `json:"sourceAutomatic,omitempty"` - // 仅在本次反序列化期间保留,用于区分旧数据缺字段与用户明确选择“保留默认值”。 - sourceConfigured bool -} - -// UnmarshalJSON 兼容来源项目的字段配置格式。来源项目使用 node/input/default/ -// bind_prompt,而画布内部使用 nodeId/fieldName/fieldValue/source;在入口统一归一化, -// 后续 RunningHub 和 Bridge 不需要各自维护一套别名解析。 -func (f *WorkflowField) UnmarshalJSON(data []byte) error { - type plainWorkflowField WorkflowField - var parsed plainWorkflowField - if err := json.Unmarshal(data, &parsed); err != nil { - return err - } - var raw map[string]json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - return err - } - *f = WorkflowField(parsed) - if f.NodeID == "" { - f.NodeID = workflowFieldRawString(raw, "node", "node_id", "nodeID") - } - if f.ClassType == "" { - f.ClassType = workflowFieldRawString(raw, "class_type", "typeName", "nodeType") - } - if f.FieldName == "" { - f.FieldName = workflowFieldRawString(raw, "input", "inputName", "input_name", "name") - } - if f.ID == "" { - f.ID = workflowFieldRawString(raw, "fieldId", "field_id", "key") - } - if f.FieldType == "" { - f.FieldType = workflowFieldRawString(raw, "type") - } - if len(f.Options) == 0 { - if options, ok := workflowFieldRawSlice(raw, "options", "values", "choices", "enum", "fieldOptions", "field_options"); ok { - f.Options = options - } - } - if f.Min == nil { - f.Min, _ = workflowFieldRawAny(raw, "min", "minValue", "min_value") - if f.Min == nil { - f.Min, _ = workflowFieldRawNestedAny(raw, "min", "minValue", "min_value") - } - } - if f.Max == nil { - f.Max, _ = workflowFieldRawAny(raw, "max", "maxValue", "max_value") - if f.Max == nil { - f.Max, _ = workflowFieldRawNestedAny(raw, "max", "maxValue", "max_value") - } - } - if f.Step == nil { - f.Step, _ = workflowFieldRawAny(raw, "step", "stepValue", "step_value") - if f.Step == nil { - f.Step, _ = workflowFieldRawNestedAny(raw, "step", "stepValue", "step_value") - } - } - if f.Label == "" { - f.Label = workflowFieldRawString(raw, "title") - } - sourceConfigured := workflowFieldRawHas(raw, "source") || workflowFieldRawHas(raw, "bind") || workflowFieldRawHas(raw, "from") - if f.Source == "" { - f.Source = workflowFieldRawString(raw, "bind", "from") - } - if !workflowFieldRawHas(raw, "fieldValue") { - if value, ok := workflowFieldRawAny(raw, "defaultValue", "default_value", "default"); ok { - f.FieldValue = value - } - } - if !workflowFieldRawHas(raw, "sourceIndex") { - f.SourceIndex = workflowFieldRawInt(raw, f.SourceIndex, "source_index", "index") - } - if !workflowFieldRawHas(raw, "imageOrder") { - f.ImageOrder = workflowFieldRawInt(raw, f.ImageOrder, "image_order") - } - if !workflowFieldRawHas(raw, "required") { - f.Required = workflowFieldRawBool(raw, f.Required, "isRequired", "is_required") - } - if !workflowFieldRawHas(raw, "randomEnabled") { - f.RandomEnabled = workflowFieldRawBool(raw, f.RandomEnabled, "random_enabled") - } - if !workflowFieldRawHas(raw, "bindPrompt") { - f.BindPrompt = workflowFieldRawBool(raw, f.BindPrompt, "bind_prompt") - } - if f.BindPrompt && strings.TrimSpace(f.Source) == "" && !sourceConfigured { - f.Source = "prompt" - } - sourceFromUpstreamConfigured := workflowFieldRawHas(raw, "sourceFromUpstream") || workflowFieldRawHas(raw, "source_from_upstream") - f.sourceConfigured = sourceConfigured || sourceFromUpstreamConfigured - if !workflowFieldRawHas(raw, "sourceFromUpstream") && workflowFieldRawHas(raw, "source_from_upstream") { - f.SourceFromUpstream = workflowFieldRawBool(raw, f.SourceFromUpstream, "source_from_upstream") - } - if !sourceConfigured && !sourceFromUpstreamConfigured && strings.TrimSpace(f.Source) == "" { - // 旧工作流没有保存动态来源时,按字段语义恢复宽高、数量、媒体等画布输入绑定。 - f.Source = workflowDynamicSource(f.FieldName, f.FieldType) - } - if !sourceConfigured && !sourceFromUpstreamConfigured && !f.SourceFromUpstream { - switch strings.ToLower(strings.TrimSpace(f.FieldType)) { - case "image", "video", "audio": - // 来源配置默认把媒体字段绑定到上游参考素材。 - f.SourceFromUpstream = true - } - } - if f.ID == "" && f.NodeID != "" && f.FieldName != "" { - f.ID = f.NodeID + "::" + f.FieldName - } - return nil -} - -func workflowFieldRawHas(raw map[string]json.RawMessage, key string) bool { - _, ok := raw[key] - return ok -} - -func workflowFieldRawString(raw map[string]json.RawMessage, keys ...string) string { - for _, key := range keys { - value, ok := raw[key] - if !ok { - continue - } - var text string - if json.Unmarshal(value, &text) == nil && strings.TrimSpace(text) != "" { - return strings.TrimSpace(text) - } - var generic interface{} - if json.Unmarshal(value, &generic) == nil && generic != nil { - text = strings.TrimSpace(fmt.Sprint(generic)) - if text != "" && text != "" { - return text - } - } - } - return "" -} - -func workflowFieldRawAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) { - for _, key := range keys { - value, ok := raw[key] - if !ok || string(value) == "null" { - continue - } - var decoded interface{} - if json.Unmarshal(value, &decoded) == nil { - return decoded, true - } - } - return nil, false -} - -func workflowFieldRawSlice(raw map[string]json.RawMessage, keys ...string) ([]interface{}, bool) { - value, ok := workflowFieldRawAny(raw, keys...) - if !ok { - return nil, false - } - if items, ok := value.([]interface{}); ok { - return items, true - } - if object, ok := value.(map[string]interface{}); ok { - for _, key := range []string{"choices", "options", "values", "enum"} { - if items, ok := object[key].([]interface{}); ok { - return items, true - } - } - } - return nil, false -} - -func workflowFieldRawNestedAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) { - for _, containerKey := range []string{"options", "values", "choices", "range", "fieldOptions", "field_options"} { - value, ok := workflowFieldRawAny(raw, containerKey) - if !ok { - continue - } - if found, ok := workflowNestedFieldValue(value, keys...); ok { - return found, true - } - } - return nil, false -} - -func workflowNestedFieldValue(value interface{}, keys ...string) (interface{}, bool) { - if items, ok := value.([]interface{}); ok { - for _, item := range items { - if found, ok := workflowNestedFieldValue(item, keys...); ok { - return found, true - } - } - return nil, false - } - object, ok := value.(map[string]interface{}) - if !ok { - return nil, false - } - for _, key := range keys { - if found, exists := object[key]; exists && found != nil { - return found, true - } - } - if nested, exists := object["range"]; exists { - return workflowNestedFieldValue(nested, keys...) - } - return nil, false -} - -func workflowFieldRawInt(raw map[string]json.RawMessage, fallback int, keys ...string) int { - value, ok := workflowFieldRawAny(raw, keys...) - if !ok { - return fallback - } - switch item := value.(type) { - case float64: - return int(item) - case json.Number: - parsed, err := strconv.Atoi(string(item)) - if err == nil { - return parsed - } - case string: - parsed, err := strconv.Atoi(strings.TrimSpace(item)) - if err == nil { - return parsed - } - } - return fallback -} - -func workflowFieldRawBool(raw map[string]json.RawMessage, fallback bool, keys ...string) bool { - value, ok := workflowFieldRawAny(raw, keys...) - if !ok { - return fallback - } - switch item := value.(type) { - case bool: - return item - case string: - parsed, err := strconv.ParseBool(strings.TrimSpace(item)) - if err == nil { - return parsed - } - } - return fallback -} - func isRunningHubInterface(value string) bool { pluginID, ok := workflowPluginIDForInterface(strings.ToLower(strings.TrimSpace(value))) return ok && pluginID == WorkflowPluginRunningHub @@ -583,1573 +311,3 @@ func runningHubUploadAuthFailure(err error) string { } return "RunningHub 参考素材上传接口认证失败(HTTP 401):ApiKey verification failed。请确认“素材上传 API Key(企业级)”有效,并且它与 Base URL 属于同一个 RunningHub 站点" } - -func workflowFieldsFromManagement(workflow map[string]interface{}, mode string) ([]WorkflowField, error) { - rawFields := collectManagementWorkflowFields(workflow, mode) - encoded, err := json.Marshal(rawFields) - if err != nil { - return nil, fmt.Errorf("工作流字段映射编码失败:%w", err) - } - var fields []WorkflowField - if err := json.Unmarshal(encoded, &fields); err != nil { - return nil, fmt.Errorf("工作流字段映射解析失败:%w", err) - } - return fields, nil -} - -func workflowFieldsForMode(fields []WorkflowField, mode string) []WorkflowField { - if len(fields) == 0 { - return fields - } - normalized := make([]WorkflowField, len(fields)) - copy(normalized, fields) - for index := range normalized { - field := &normalized[index] - if canonical := workflowNamedDynamicSource(field.Source, mode); canonical != "" { - field.Source = canonical - } - inferred := workflowDynamicSourceForMode(field.FieldName, field.FieldType, mode) - if strings.TrimSpace(field.Source) == "" && !field.sourceConfigured && !field.BindPrompt { - field.Source = inferred - if inferred != "" { - field.SourceAutomatic = ptr(true) - } - continue - } - if shouldRepairLegacyWorkflowSource(field.FieldName, field.Source, inferred, mode) { - field.Source = inferred - } - } - return normalized -} - -func shouldRepairLegacyWorkflowSource(fieldName string, source string, inferred string, mode string) bool { - sourceKey := normalizeManagementFieldName(source) - inferredKey := normalizeManagementFieldName(inferred) - if sourceKey == "" || inferredKey == "" || sourceKey == inferredKey { - return false - } - fieldKey := normalizeManagementFieldName(fieldName) - wasMistakenForMedia := sourceKey == "referenceimage" || sourceKey == "referencevideo" || sourceKey == "referenceaudio" - if wasMistakenForMedia && (workflowDimensionNameSource(fieldKey) != "" || inferredKey == "aspectratio" || inferredKey == "vquality") { - return true - } - mode = strings.ToLower(strings.TrimSpace(mode)) - return (fieldKey == "resolution" && (mode == "video" && sourceKey == "size" || mode == "image" && sourceKey == "vquality")) || (fieldKey == "quality" && sourceKey == "vquality") -} - -func validateWorkflowMediaInputs(fields []WorkflowField, input canvasGenerationInput) error { - capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0} - hasMaskMapping := false - for _, field := range fields { - if field.Enabled != nil && !*field.Enabled { - continue - } - source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") - if source == "mask" { - hasMaskMapping = true - continue - } - key := "" - switch source { - case "referenceimage", "image", "referenceimages": - key = "referenceimage" - case "referencevideo", "video", "referencevideos": - key = "referencevideo" - case "referenceaudio", "audio", "referenceaudios": - key = "referenceaudio" - } - if key == "" { - continue - } - index := field.SourceIndex - if field.ImageOrder > 0 { - index = field.ImageOrder - 1 - } - if index < 0 { - index = 0 - } - if index+1 > capacities[key] { - capacities[key] = index + 1 - } - } - checks := []struct { - label string - count int - capacity int - }{ - {label: "参考图片", count: len(input.ReferenceImages), capacity: capacities["referenceimage"]}, - {label: "参考视频", count: len(input.ReferenceVideos), capacity: capacities["referencevideo"]}, - {label: "参考音频", count: len(input.ReferenceAudios), capacity: capacities["referenceaudio"]}, - } - for _, check := range checks { - if check.count > check.capacity { - return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在工作流字段映射中补齐槽位", check.capacity, check.label, check.count) - } - } - if input.Mask != nil && !hasMaskMapping { - return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射") - } - return nil -} - -func runningHubNodeInfo(fields []WorkflowField, files map[string]string, input canvasGenerationInput) ([]map[string]any, error) { - return runningHubNodeInfoWithWorkflow(fields, files, input, nil) -} - -func runningHubNodeInfoWithWorkflow(fields []WorkflowField, files map[string]string, input canvasGenerationInput, workflow map[string]interface{}) ([]map[string]any, error) { - items := make([]map[string]any, 0, len(fields)+3) - for _, field := range fields { - if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { - continue - } - // RunningHub 的 nodeInfoList 只接受字符串 fieldValue。部分内部节点不会把 - // 字符串恢复为数值,因此安全边界必须由服务端强制执行,不能依赖前端隐藏。 - if isRunningHubUnsafeInternalField(workflow, field) { - continue - } - value, present, err := resolveWorkflowFieldValue(field, files, input) - if err != nil { - return nil, err - } - if !present { - if field.Required { - return nil, fmt.Errorf("工作流字段 %s 缺少值", firstNonEmptyString(field.ID, field.FieldName)) - } - continue - } - if err := validateRunningHubWorkflowFieldValue(field, value); err != nil { - return nil, err - } - encoded := workflowScalarString(value) - if !shouldSendRunningHubWorkflowField(workflow, field, encoded) { - continue - } - items = append(items, map[string]any{"nodeId": field.NodeID, "fieldName": field.FieldName, "fieldValue": encoded}) - } - return items, nil -} - -func shouldSendRunningHubWorkflowField(workflow map[string]interface{}, field WorkflowField, encoded string) bool { - if len(workflow) == 0 { - // AI App 只有公开参数列表,没有可用于比较的工作流 JSON。 - return true - } - node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}) - if !ok { - return true - } - inputs, ok := node["inputs"].(map[string]interface{}) - if !ok { - return true - } - original, exists := inputs[strings.TrimSpace(field.FieldName)] - if exists && isWorkflowLinkValue(original) { - // 已连接输入属于工作流拓扑,不能被旧字段映射拆成字符串覆盖。 - return false - } - if normalizeWorkflowFieldSource(field) != "" || field.RandomEnabled { - return !isRunningHubAutomaticInternalBinding(node, field) - } - if !exists { - return true - } - // 拉取参数时会保存所有 widget 的默认值;未修改项无需重复覆盖工作流。 - return encoded != workflowScalarString(original) -} - -func isRunningHubAutomaticInternalBinding(node map[string]interface{}, field WorkflowField) bool { - if field.SourceAutomatic != nil && !*field.SourceAutomatic { - return false - } - classType := strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) - if classType != "imageresize+" { - return false - } - // 旧配置没有 sourceAutomatic,但 ImageResize+ 的宽高来源是按同名字段自动误判的。 - // 保留节点自身尺寸链;用户仍可将来源设为默认并明确修改静态值。 - source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") - return source == "size" || source == "imagesize" || source == "sizewidth" || source == "width" || source == "imagewidth" || source == "videowidth" || source == "sizeheight" || source == "height" || source == "imageheight" || source == "videoheight" -} - -func isRunningHubUnsafeInternalField(workflow map[string]interface{}, field WorkflowField) bool { - if field.SafeToOverride != nil && !*field.SafeToOverride { - return true - } - classType := strings.ToLower(strings.TrimSpace(field.ClassType)) - if node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}); ok { - classType = strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) - } - fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) - if classType == "int" && fieldName == "value" { - return true - } - return classType == "imageresize+" && (fieldName == "width" || fieldName == "height" || fieldName == "multiple_of") -} - -func validateRunningHubWorkflowFieldValue(field WorkflowField, value interface{}) error { - fieldID := firstNonEmptyString(strings.TrimSpace(field.ID), strings.TrimSpace(field.NodeID)+"."+strings.TrimSpace(field.FieldName)) - fieldType := strings.ToUpper(strings.TrimSpace(field.FieldType)) - if fieldType == "NUMBER" || fieldType == "FLOAT" || fieldType == "INTEGER" || fieldType == "INT" || fieldType == "SLIDER" { - numeric, ok := workflowNumericBound(value) - if !ok { - return fmt.Errorf("工作流字段 %s 不是有效数字", fieldID) - } - minValue, hasMin := workflowNumericBound(field.Min) - maxValue, hasMax := workflowNumericBound(field.Max) - stepValue, hasStep := workflowNumericBound(field.Step) - if hasMin && hasMax && minValue > maxValue { - return fmt.Errorf("工作流字段 %s 的最小值不能大于最大值", fieldID) - } - if hasStep && stepValue <= 0 { - return fmt.Errorf("工作流字段 %s 的步长必须大于 0", fieldID) - } - if (hasMin && numeric < minValue) || (hasMax && numeric > maxValue) { - return fmt.Errorf("工作流字段 %s 的值超出允许范围", fieldID) - } - if hasStep { - start := float64(0) - if hasMin { - start = minValue - } - steps := (numeric - start) / stepValue - if math.Abs(steps-math.Round(steps)) > 1e-7 { - return fmt.Errorf("工作流字段 %s 的值不符合步长", fieldID) - } - } - } - if fieldType == "BOOLEAN" || fieldType == "BOOL" { - switch item := value.(type) { - case bool: - case string: - normalized := strings.ToLower(strings.TrimSpace(item)) - if normalized != "true" && normalized != "false" { - return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) - } - default: - return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) - } - } - if options := workflowFieldAllowedOptions(field); len(options) > 0 { - encoded := strings.TrimSpace(workflowScalarString(value)) - matched := false - hasScalarOption := false - for _, option := range options { - if workflowOptionIsRange(option) { - continue - } - candidate := strings.TrimSpace(workflowOptionString(option)) - if candidate == "" || candidate == "" { - continue - } - hasScalarOption = true - if candidate == encoded { - matched = true - break - } - } - if hasScalarOption && !matched { - return fmt.Errorf("工作流字段 %s 的值不在允许选项中", fieldID) - } - } - return nil -} - -func workflowOptionIsRange(value interface{}) bool { - object, ok := value.(map[string]interface{}) - if !ok { - return false - } - for _, key := range []string{"min", "max", "step", "minValue", "maxValue", "stepValue"} { - if _, exists := object[key]; exists { - return true - } - } - if nested, ok := object["range"].(map[string]interface{}); ok { - return workflowOptionIsRange(nested) - } - return false -} - -func resolveWorkflowFieldValue(field WorkflowField, files map[string]string, input canvasGenerationInput) (interface{}, bool, error) { - source := normalizeWorkflowFieldSource(field) - // 画布参数按 nodeId + fieldName 独立保存时 source 可能为空;枚举字段仍必须 - // 使用工作流原始 options 的完整值,不能把用户界面上的比例简称直接发给 ComfyUI。 - if source == "" && strings.EqualFold(strings.TrimSpace(field.FieldName), "aspect_ratio") { - // 旧版前端只把比例写入 Config.Size,没有同步改写字段映射。此时不能 - // 直接使用工作流保存的默认值,否则用户选择 9:16 会在提交前静默回到 1:1。 - // 仅对非 1:1 的显式比例做回填,避免没有动态参数时覆盖工作流自己的 1:1 默认值。 - requested := strings.TrimSpace(input.Config.Size) - if requested != "" && !strings.EqualFold(workflowAspectRatio(requested), "1:1") { - value := workflowAspectRatioValue(field, requested) - if strings.TrimSpace(fmt.Sprint(value)) != "" { - return value, true, nil - } - } - raw := strings.TrimSpace(workflowOptionString(firstNonNilWorkflowValue(field.FieldValue, field.Value))) - if raw != "" { - value := workflowAspectRatioValue(field, raw) - return value, true, nil - } - } - if source != "" { - switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { - case "prompt", "text", "positiveprompt", "positive": - return input.Prompt, strings.TrimSpace(input.Prompt) != "", nil - case "size", "imagesize": - return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil - case "resolution": - if strings.EqualFold(strings.TrimSpace(input.Mode), "video") { - value := workflowVideoResolutionValue(field, input.Config.VQuality) - return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil - } - return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil - case "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio": - value := workflowAspectRatioValue(field, input.Config.Size) - return value, value != "", nil - case "sizewidth", "width", "imagewidth", "videowidth": - value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 0) - return value, value != "", nil - case "sizeheight", "height", "imageheight", "videoheight": - value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 1) - return value, value != "", nil - case "quality": - return input.Config.Quality, strings.TrimSpace(input.Config.Quality) != "", nil - case "count", "batch", "batchsize": - return input.Config.Count, strings.TrimSpace(input.Config.Count) != "", nil - case "videoseconds", "video_seconds", "duration": - value := workflowVideoDurationValue(field, input.Config.VideoSeconds) - return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil - case "vquality", "videoquality", "video_quality": - value := workflowVideoResolutionValue(field, input.Config.VQuality) - return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil - case "videogenerateaudio", "video_generate_audio", "generateaudio": - return input.Config.VideoGenerateAudio, strings.TrimSpace(input.Config.VideoGenerateAudio) != "", nil - case "videowatermark", "video_watermark", "watermark": - return input.Config.VideoWatermark, strings.TrimSpace(input.Config.VideoWatermark) != "", nil - case "audioformat", "audio_format": - return input.Config.AudioFormat, strings.TrimSpace(input.Config.AudioFormat) != "", nil - case "systemprompt", "system_prompt": - return input.Config.SystemPrompt, strings.TrimSpace(input.Config.SystemPrompt) != "", nil - case "transparentbackground", "transparent_background": - return input.Config.TransparentBackground, strings.TrimSpace(input.Config.TransparentBackground) != "", nil - case "audiovoice", "audio_voice", "voice": - return input.Config.AudioVoice, strings.TrimSpace(input.Config.AudioVoice) != "", nil - case "audiospeed", "audio_speed": - return input.Config.AudioSpeed, strings.TrimSpace(input.Config.AudioSpeed) != "", nil - case "audioinstructions", "audio_instructions": - return input.Config.AudioInstructions, strings.TrimSpace(input.Config.AudioInstructions) != "", nil - } - index := field.SourceIndex - if field.ImageOrder > 0 { - index = field.ImageOrder - 1 - } - var media providerMedia - switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { - case "referenceimage", "image", "referenceimages": - if index < 0 || index >= len(input.ReferenceImages) { - return nil, false, nil - } - media = input.ReferenceImages[index] - case "referencevideo", "video", "referencevideos": - if index < 0 || index >= len(input.ReferenceVideos) { - return nil, false, nil - } - media = input.ReferenceVideos[index] - case "referenceaudio", "audio", "referenceaudios": - if index < 0 || index >= len(input.ReferenceAudios) { - return nil, false, nil - } - media = input.ReferenceAudios[index] - case "mask": - if input.Mask == nil { - return nil, false, nil - } - media = *input.Mask - default: - return nil, false, fmt.Errorf("不支持的工作流字段来源:%s", field.Source) - } - name := files[media.ID] - if name == "" { - return nil, false, errors.New("工作流参考素材尚未上传") - } - return name, true, nil - } - if field.RandomEnabled { - randomMax := field.Max - if isRunningHubInterface(input.Config.InterfaceType) && isWorkflowSeedField(field.FieldName) { - // RunningHub 的随机 Seed 按 uint32 传输,不能沿用 JavaScript 安全整数上限。 - const runningHubSeedMax int64 = 1<<32 - 1 - maxValue, err := workflowIntegerBound(field.Max, runningHubSeedMax) - if err != nil { - return nil, false, err - } - if maxValue > runningHubSeedMax { - maxValue = runningHubSeedMax - } - randomMax = maxValue - } - value, err := randomWorkflowInteger(field.Min, randomMax) - return value, err == nil, err - } - if field.FieldValue != nil { - return field.FieldValue, true, nil - } - if field.Value != nil { - return field.Value, true, nil - } - return nil, false, nil -} - -func firstNonNilWorkflowValue(values ...interface{}) interface{} { - for _, value := range values { - if value != nil { - return value - } - } - return nil -} - -func normalizeWorkflowFieldSource(field WorkflowField) string { - source := strings.ToLower(strings.TrimSpace(field.Source)) - if source == "" && field.BindPrompt { - return "prompt" - } - if source != "" { - return source - } - if !field.SourceFromUpstream { - return "" - } - fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) - if fieldName == "prompt" || fieldName == "text" || fieldName == "positive_prompt" || fieldName == "positiveprompt" { - return "prompt" - } - switch strings.ToLower(strings.TrimSpace(field.FieldType)) { - case "image", "img", "photo", "picture": - return "referenceimage" - case "video", "movie": - return "referencevideo" - case "audio", "sound", "music", "voice": - return "referenceaudio" - default: - return "" - } -} - -func workflowDimensionPart(mode string, size string, videoQuality string, index int) string { - if index < 0 || index > 1 { - return "" - } - if dimensions, ok := workflowPixelDimensions(size); ok { - return strconv.Itoa(dimensions[index]) - } - if strings.EqualFold(strings.TrimSpace(mode), "video") { - dimensions, ok := workflowVideoDimensions(size, videoQuality) - if !ok { - return "" - } - return strconv.Itoa(dimensions[index]) - } - dimensions, ok := workflowImageDimensions(size) - if !ok { - return "" - } - return strconv.Itoa(dimensions[index]) -} - -func workflowPixelDimensions(value string) ([2]int, bool) { - var result [2]int - normalized := strings.ToLower(strings.TrimSpace(value)) - if !strings.Contains(normalized, "x") { - return result, false - } - parts := strings.FieldsFunc(normalized, func(r rune) bool { return r == 'x' || r == ' ' || r == ',' }) - if len(parts) != 2 { - return result, false - } - for index, part := range parts { - parsed, err := strconv.Atoi(strings.TrimSpace(part)) - if err != nil || parsed <= 0 { - return result, false - } - result[index] = parsed - } - return result, true -} - -func workflowAspectRatio(value string) string { - normalized := strings.ToLower(strings.TrimSpace(value)) - // RunningHub 的 ResolutionSelector 选项带有展示文案(例如 - // "9:16 (Portrait Widescreen)"),协议比较只需要比例前缀。 - if cut := strings.IndexAny(normalized, " ("); cut >= 0 { - normalized = strings.TrimSpace(normalized[:cut]) - } - for _, suffix := range []string{"-1k", "-2k", "-4k"} { - if strings.HasSuffix(normalized, suffix) { - normalized = strings.TrimSuffix(normalized, suffix) - break - } - } - if width, height, ok := workflowRatioParts(normalized); ok { - divisor := workflowGreatestCommonDivisor(width, height) - return fmt.Sprintf("%d:%d", width/divisor, height/divisor) - } - dimensions, ok := workflowPixelDimensions(normalized) - if !ok { - return "" - } - ratio := float64(dimensions[0]) / float64(dimensions[1]) - known := [][2]int{{1, 1}, {3, 2}, {2, 3}, {4, 3}, {3, 4}, {4, 5}, {5, 4}, {16, 9}, {9, 16}, {2, 1}, {1, 2}, {21, 9}} - bestDifference := math.MaxFloat64 - best := [2]int{} - for _, candidate := range known { - expected := float64(candidate[0]) / float64(candidate[1]) - difference := math.Abs(ratio-expected) / expected - if difference < bestDifference { - bestDifference = difference - best = candidate - } - } - if bestDifference <= 0.03 { - return fmt.Sprintf("%d:%d", best[0], best[1]) - } - divisor := workflowGreatestCommonDivisor(dimensions[0], dimensions[1]) - return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor) -} - -func workflowAspectRatioValue(field WorkflowField, value string) string { - raw := strings.TrimSpace(value) - if options := workflowFieldAllowedOptions(field); len(options) > 0 { - requested := workflowAspectRatio(raw) - for _, option := range options { - candidate := strings.TrimSpace(workflowOptionString(option)) - if candidate != "" && strings.EqualFold(workflowAspectRatio(candidate), requested) { - return candidate - } - } - if fallback := workflowFieldConfiguredDefault(field); fallback != "" { - return fallback - } - return raw - } - // “auto/adaptive” 是工作流的真实模式,不应被画布当前像素尺寸反推成 - // 一个并不存在的比例;宽高字段仍按工作流自身的默认值或显式尺寸处理。 - if fallback := strings.TrimSpace(workflowFieldConfiguredDefault(field)); strings.EqualFold(fallback, "auto") || strings.EqualFold(fallback, "adaptive") { - return fallback - } - return workflowAspectRatio(raw) -} - -func workflowFieldAllowedOptions(field WorkflowField) []interface{} { - classType := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.ClassType), "_", ""), "-", "")) - fieldName := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.FieldName), "_", ""), "-", "")) - if classType == "resolutionselector" && fieldName == "aspectratio" { - // RunningHub 的工作流 API 不返回 object_info;ResolutionSelector 的完整枚举 - // 是节点协议的一部分,通用比例预设(9:16 等)不能直接提交给该节点。 - return []interface{}{ - "1:1 (Square)", - "2:3 (Portrait Photo)", - "3:2 (Photo)", - "3:4 (Portrait Standard)", - "4:3 (Standard)", - "9:16 (Portrait Widescreen)", - "16:9 (Widescreen)", - "21:9 (Ultrawide)", - } - } - return field.Options -} - -func workflowRatioParts(value string) (int, int, bool) { - parts := strings.Split(strings.TrimSpace(value), ":") - if len(parts) != 2 { - return 0, 0, false - } - width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) - height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) - return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0 -} - -func workflowGreatestCommonDivisor(left int, right int) int { - for right != 0 { - left, right = right, left%right - } - if left <= 0 { - return 1 - } - return left -} - -func workflowImageDimensions(value string) ([2]int, bool) { - normalized := strings.ToLower(strings.TrimSpace(value)) - preset := map[string][2]int{ - "1:1": {1024, 1024}, "3:2": {1536, 1024}, "2:3": {1024, 1536}, - "4:3": {1360, 1024}, "3:4": {1024, 1360}, "4:5": {1024, 1280}, "5:4": {1280, 1024}, - "16:9": {1824, 1024}, "9:16": {1024, 1824}, "2:1": {2048, 1024}, "1:2": {1024, 2048}, - "21:9": {2352, 1008}, "1:1-2k": {2048, 2048}, "16:9-2k": {2048, 1152}, - "9:16-2k": {1152, 2048}, "16:9-4k": {3840, 2160}, "9:16-4k": {2160, 3840}, - } - if dimensions, ok := preset[normalized]; ok { - return dimensions, true - } - ratio := workflowAspectRatio(normalized) - widthRatio, heightRatio, ok := workflowRatioParts(ratio) - if !ok { - return [2]int{}, false - } - tier := "1k" - if strings.HasSuffix(normalized, "-2k") { - tier = "2k" - } else if strings.HasSuffix(normalized, "-4k") { - tier = "4k" - } - if tier == "1k" { - if widthRatio >= heightRatio { - return [2]int{workflowRoundToStep(1024*float64(widthRatio)/float64(heightRatio), 16), 1024}, true - } - return [2]int{1024, workflowRoundToStep(1024*float64(heightRatio)/float64(widthRatio), 16)}, true - } - longEdge := 2048 - if tier == "4k" { - longEdge = 3840 - } - if widthRatio >= heightRatio { - return [2]int{longEdge, workflowRoundToStep(float64(longEdge)*float64(heightRatio)/float64(widthRatio), 16)}, true - } - return [2]int{workflowRoundToStep(float64(longEdge)*float64(widthRatio)/float64(heightRatio), 16), longEdge}, true -} - -func workflowVideoDimensions(size string, quality string) ([2]int, bool) { - ratio := workflowAspectRatio(size) - widthRatio, heightRatio, ok := workflowRatioParts(ratio) - shortEdge := workflowVideoResolutionPixels(quality) - if !ok || shortEdge <= 0 { - return [2]int{}, false - } - if widthRatio >= heightRatio { - return [2]int{workflowRoundToStep(float64(shortEdge)*float64(widthRatio)/float64(heightRatio), 2), shortEdge}, true - } - return [2]int{shortEdge, workflowRoundToStep(float64(shortEdge)*float64(heightRatio)/float64(widthRatio), 2)}, true -} - -func workflowRoundToStep(value float64, step int) int { - if step <= 1 { - return int(math.Round(value)) - } - return int(math.Round(value/float64(step))) * step -} - -func workflowVideoResolutionPixels(value string) int { - normalized := strings.ToLower(strings.TrimSpace(value)) - switch normalized { - case "low": - return 480 - case "auto", "default", "medium", "high": - return 720 - case "2k": - return 1440 - case "4k": - return 2160 - } - parsed, err := strconv.Atoi(strings.TrimSuffix(normalized, "p")) - if err != nil || parsed <= 0 { - return 0 - } - return parsed -} - -func workflowVideoResolutionValue(field WorkflowField, value string) interface{} { - raw := strings.TrimSpace(value) - if raw == "" { - return "" - } - if len(field.Options) > 0 { - requested := normalizeWorkflowResolutionToken(raw) - for _, option := range field.Options { - candidate := strings.TrimSpace(workflowOptionString(option)) - if candidate != "" && normalizeWorkflowResolutionToken(candidate) == requested { - // nodeInfoList 的 fieldValue 是字符串;对象选项只取其 value/id 等标量。 - return candidate - } - } - // 工作流明确声明了可选项但画布值不在其中时,保留工作流默认值, - // 不再把 2K/4K/720 等普通模型别名硬塞给工作流。 - if fallback := workflowFieldConfiguredDefault(field); fallback != "" { - for _, option := range field.Options { - candidate := strings.TrimSpace(workflowOptionString(option)) - if candidate != "" && normalizeWorkflowResolutionToken(candidate) == normalizeWorkflowResolutionToken(fallback) { - return candidate - } - } - return fallback - } - return raw - } - if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { - return numeric - } - if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { - return fallback - } - return raw -} - -func workflowVideoDurationValue(field WorkflowField, value string) interface{} { - raw := strings.TrimSpace(value) - if raw == "" { - return "" - } - if len(field.Options) > 0 { - requested := normalizeWorkflowDurationToken(raw) - for _, option := range field.Options { - candidate := strings.TrimSpace(workflowOptionString(option)) - if candidate != "" && normalizeWorkflowDurationToken(candidate) == requested { - return candidate - } - } - if fallback := workflowFieldConfiguredDefault(field); fallback != "" { - for _, option := range field.Options { - candidate := strings.TrimSpace(workflowOptionString(option)) - if candidate != "" && normalizeWorkflowDurationToken(candidate) == normalizeWorkflowDurationToken(fallback) { - return candidate - } - } - return fallback - } - return raw - } - if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { - return numeric - } - if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { - return fallback - } - return raw -} - -func normalizeWorkflowDurationToken(value string) string { - return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "s"), "秒"))) -} - -func workflowFieldConfiguredDefault(field WorkflowField) string { - value := field.FieldValue - if value == nil { - value = field.Value - } - if value == nil { - return "" - } - return strings.TrimSpace(workflowOptionString(value)) -} - -func normalizeWorkflowResolutionToken(value string) string { - return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P"))) -} - -func workflowNumericValue(value string) *float64 { - parsed, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P")), 64) - if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { - return nil - } - return &parsed -} - -func workflowResolutionShapeCompatible(defaultValue string, requested string) bool { - defaultNumeric := workflowNumericValue(defaultValue) - requestedNumeric := workflowNumericValue(requested) - if defaultNumeric != nil && requestedNumeric != nil { - return true - } - return strings.EqualFold(strings.TrimSpace(defaultValue), strings.TrimSpace(requested)) -} - -func workflowNumericResolutionValue(field WorkflowField, raw string) interface{} { - parsed := workflowNumericValue(raw) - if parsed == nil { - return nil - } - min, minOK := workflowNumericBound(field.Min) - max, maxOK := workflowNumericBound(field.Max) - step, stepOK := workflowNumericBound(field.Step) - if !minOK && !maxOK && !stepOK && !strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") { - return nil - } - value := *parsed - if minOK && value < min { - value = min - } - if maxOK && value > max { - value = max - } - if stepOK && step > 0 { - anchor := min - if !minOK { - anchor = 0 - } - value = anchor + math.Round((value-anchor)/step)*step - } - if strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") && math.Trunc(value) == value { - return int64(value) - } - return value -} - -func workflowNumericBound(value interface{}) (float64, bool) { - parsed, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(value)), 64) - return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) -} - -func workflowOptionString(value interface{}) string { - if object, ok := value.(map[string]interface{}); ok { - for _, key := range []string{"value", "id", "key", "label", "name"} { - if candidate := strings.TrimSpace(fmt.Sprint(object[key])); candidate != "" && candidate != "" { - return candidate - } - } - } - return fmt.Sprint(value) -} - -func runningHubPromptFallback(workflow map[string]interface{}, prompt string) []map[string]any { - if strings.TrimSpace(prompt) == "" || len(workflow) == 0 { - return nil - } - bestNodeID := "" - bestFieldName := "" - bestScore := -1000 - nodeIDs := make([]string, 0, len(workflow)) - for nodeID := range workflow { - nodeIDs = append(nodeIDs, nodeID) - } - sort.Slice(nodeIDs, func(i, j int) bool { return managementNodeIDLess(nodeIDs[i], nodeIDs[j]) }) - for _, nodeID := range nodeIDs { - node, ok := workflow[nodeID].(map[string]interface{}) - if !ok { - continue - } - inputs, ok := node["inputs"].(map[string]interface{}) - if !ok { - continue - } - fieldNames := make([]string, 0, len(inputs)) - for fieldName := range inputs { - fieldNames = append(fieldNames, fieldName) - } - sort.Strings(fieldNames) - classType := strings.ToLower(strings.TrimSpace(fmt.Sprint(node["class_type"]))) - metaTitle := strings.ToLower(strings.TrimSpace(fmt.Sprint(workflowNodeMetaTitle(node)))) - for _, fieldName := range fieldNames { - if !isWorkflowPromptCandidate(fieldName, classType, metaTitle) { - continue - } - if isWorkflowLinkValue(inputs[fieldName]) { - continue - } - score := workflowPromptCandidateScore(fieldName, classType, metaTitle) - if bestNodeID == "" || score > bestScore { - bestNodeID = nodeID - bestFieldName = fieldName - bestScore = score - } - } - } - if bestNodeID != "" { - return []map[string]any{{"nodeId": bestNodeID, "fieldName": bestFieldName, "fieldValue": prompt}} - } - return nil -} - -func isWorkflowMediaSource(source string) bool { - normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(source)), "_", ""), "-", "") - switch normalized { - case "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask": - return true - default: - return false - } -} - -func workflowPromptCandidateScore(fieldName string, classType string, metaTitle string) int { - descriptor := strings.ToLower(strings.TrimSpace(fieldName + " " + metaTitle)) - score := 0 - for _, marker := range []string{"negative", "neg prompt", "负面", "反向", "负向"} { - if strings.Contains(descriptor, marker) { - score -= 100 - break - } - } - for _, marker := range []string{"positive", "正面", "正向"} { - if strings.Contains(descriptor, marker) { - score += 20 - break - } - } - if strings.Contains(classType, "cliptextencode") { - score += 5 - } - if isWorkflowPromptFieldName(fieldName) { - score += 2 - } - return score -} - -func randomWorkflowInteger(rawMin interface{}, rawMax interface{}) (int64, error) { - const defaultMax int64 = 9007199254740991 - minValue, err := workflowIntegerBound(rawMin, 0) - if err != nil { - return 0, err - } - maxValue, err := workflowIntegerBound(rawMax, defaultMax) - if err != nil { - return 0, err - } - if maxValue < minValue { - return 0, errors.New("工作流随机值最大值不能小于最小值") - } - rangeSize := new(big.Int).Sub(big.NewInt(maxValue), big.NewInt(minValue)) - rangeSize.Add(rangeSize, big.NewInt(1)) - offset, err := cryptorand.Int(cryptorand.Reader, rangeSize) - if err != nil { - return 0, fmt.Errorf("生成工作流随机值失败:%w", err) - } - return new(big.Int).Add(offset, big.NewInt(minValue)).Int64(), nil -} - -func workflowIntegerBound(value interface{}, fallback int64) (int64, error) { - if value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" { - return fallback, nil - } - parsed, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64) - if err != nil { - return 0, fmt.Errorf("工作流随机值范围不是有效整数:%v", value) - } - return parsed, nil -} - -func workflowFieldsBindPrompt(fields []WorkflowField) bool { - for _, field := range fields { - if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { - continue - } - source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") - switch source { - case "prompt", "text", "positiveprompt", "positive": - return true - } - } - return false -} - -func upsertRunningHubNodeInfo(items []map[string]any, overrides []map[string]any) []map[string]any { - for _, override := range overrides { - nodeID := strings.TrimSpace(stringValue(override["nodeId"])) - fieldName := strings.TrimSpace(stringValue(override["fieldName"])) - replaced := false - for index, item := range items { - if strings.TrimSpace(stringValue(item["nodeId"])) == nodeID && strings.TrimSpace(stringValue(item["fieldName"])) == fieldName { - items[index] = override - replaced = true - break - } - } - if !replaced { - items = append(items, override) - } - } - return items -} - -func isWorkflowLinkValue(value interface{}) bool { - items, ok := value.([]interface{}) - return ok && len(items) == 2 && fmt.Sprint(items[0]) != "" && isIntegerLike(items[1]) -} - -func isIntegerLike(value interface{}) bool { - switch item := value.(type) { - case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: - return true - case float64: - return item == float64(int(item)) - case json.Number: - _, err := strconv.Atoi(string(item)) - return err == nil - default: - return false - } -} - -func isWorkflowPromptFieldName(value string) bool { - normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) - switch normalized { - case "text", "prompt", "positiveprompt", "positive", "caption", "description": - return true - default: - return false - } -} - -func isWorkflowPromptCandidate(fieldName string, classType string, metaTitle string) bool { - if isWorkflowPromptFieldName(fieldName) { - return true - } - normalizedField := strings.ToLower(strings.TrimSpace(fieldName)) - if normalizedField != "value" { - return false - } - return strings.Contains(classType, "text") || strings.Contains(classType, "string") || strings.Contains(classType, "prompt") || strings.Contains(metaTitle, "text") || strings.Contains(metaTitle, "prompt") || strings.Contains(metaTitle, "提示词") || strings.Contains(metaTitle, "文本") -} - -func workflowNodeMetaTitle(node map[string]interface{}) string { - meta, _ := node["_meta"].(map[string]interface{}) - return fmt.Sprint(meta["title"]) -} - -func workflowScalarString(value interface{}) string { - switch item := value.(type) { - case string: - return item - case nil: - return "" - default: - encoded, err := json.Marshal(item) - if err == nil { - return string(encoded) - } - return fmt.Sprint(item) - } -} - -func (s *Service) runningHubJSON(ctx context.Context, config providerConfig, endpoint string, body interface{}, target *map[string]any) error { - encoded, err := json.Marshal(body) - if err != nil { - return err - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") - ApplyOutboundHeaders(req, config.Headers) - data, _, err := doBinary(req) - if err != nil { - return err - } - if err := json.Unmarshal(data, target); err != nil { - return err - } - return nil -} - -func (s *Service) pollRunningHubWorkflow(ctx context.Context, config providerConfig, root string, taskID string, mode string) (map[string]interface{}, error) { - if mode == "video" { - return s.pollRunningHubVideoWorkflowWithPolicy(ctx, config, root, taskID, defaultVideoPollPolicy()) - } - return s.pollRunningHubWorkflowLegacy(ctx, config, root, taskID) -} - -func (s *Service) pollRunningHubVideoWorkflowWithPolicy(ctx context.Context, config providerConfig, root string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) { - return runVideoPollLoop(ctx, taskID, policy, func(ctx context.Context) (videoPollOutcome, error) { - var response map[string]any - err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response) - if err != nil { - return videoPollOutcome{}, fmt.Errorf("RunningHub 查询任务失败:%w", err) - } - code, validCode := runningHubPayloadCode(response) - if !validCode { - validCode = len(runningHubOutputURLs(response["data"])) > 0 - code = 0 - } - if !validCode { - return videoPollOutcome{}, errors.New("RunningHub 查询响应缺少可识别状态") - } - if code == 0 { - urls := runningHubOutputURLs(response["data"]) - if len(urls) == 0 { - return videoPollOutcome{}, errors.New("RunningHub 任务成功但没有返回产物") - } - for index, rawURL := range urls { - urls[index] = resolveRunningHubOutputURL(root, rawURL) - } - result, err := s.downloadWorkflowVideoOutputs(ctx, urls, taskID, policy) - if err != nil { - return videoPollOutcome{}, err - } - _ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil) - return videoPollOutcome{Done: true, Result: result}, nil - } - if code == 805 || code == 806 { - return videoPollOutcome{}, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response)) - } - stage := "running" - if code == 813 { - stage = "queued" - } else if code != 804 { - stage = "pending" - } - next := time.Now().Add(policy.Interval) - _ = s.updateWorkflowProviderState(ctx, taskID, stage, &next) - return videoPollOutcome{}, nil - }) -} - -func (s *Service) pollRunningHubWorkflowLegacy(ctx context.Context, config providerConfig, root string, taskID string) (map[string]interface{}, error) { - for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { - var response map[string]any - err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response) - if err != nil { - return nil, fmt.Errorf("RunningHub 查询任务失败:%w", err) - } - code, validCode := runningHubPayloadCode(response) - if !validCode { - // 成功响应有时只有 outputs/results,没有显式状态码。 - validCode = len(runningHubOutputURLs(response["data"])) > 0 - code = 0 - } - if !validCode { - return nil, errors.New("RunningHub 查询响应缺少可识别状态") - } - if code == 0 { - urls := runningHubOutputURLs(response["data"]) - if len(urls) == 0 { - return nil, errors.New("RunningHub 任务成功但没有返回产物") - } - for index, rawURL := range urls { - urls[index] = resolveRunningHubOutputURL(root, rawURL) - } - _ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil) - return s.downloadWorkflowOutputs(ctx, urls) - } - if code == 805 || code == 806 { - return nil, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response)) - } - stage := "running" - if code == 813 { - stage = "queued" - } else if code != 804 { - // RunningHub 会扩展暂态码;只对明确失败码结束任务,其余状态继续轮询到终态或超时。 - stage = "pending" - } - _ = s.updateWorkflowProviderState(ctx, taskID, stage, ptr(time.Now().Add(2500*time.Millisecond))) - if err := sleepContext(ctx, 2500*time.Millisecond); err != nil { - return nil, err - } - } - return nil, fmt.Errorf("RunningHub 任务超时(%s)", taskID) -} - -// RunningHub 工作流的管理、提交和轮询固定使用积分 API Key;企业级 Key 仅由上传接口单独读取。 -func runningHubAPIKey(config providerConfig) string { - return strings.TrimSpace(config.APIKey) -} - -func (s *Service) downloadWorkflowOutputs(ctx context.Context, urls []string) (map[string]interface{}, error) { - return s.downloadWorkflowOutputsWithPolicy(ctx, urls, "", nil) -} - -func (s *Service) downloadWorkflowVideoOutputs(ctx context.Context, urls []string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) { - return s.downloadWorkflowOutputsWithPolicy(ctx, urls, taskID, &policy) -} - -func (s *Service) downloadWorkflowOutputsWithPolicy(ctx context.Context, urls []string, taskID string, policy *videoPollPolicy) (map[string]interface{}, error) { - images := make([]map[string]interface{}, 0) - var video, audio map[string]interface{} - for _, rawURL := range urls { - if strings.HasPrefix(rawURL, "data:") { - mimeType, data, err := decodeProviderDataURL(rawURL) - if err != nil { - return nil, err - } - if item := workflowOutputValue(mimeType, data); item != nil { - switch value := item.(type) { - case map[string]interface{}: - if strings.HasPrefix(mimeType, "image/") { - images = append(images, value) - } else if strings.HasPrefix(mimeType, "video/") { - video = value - } else if strings.HasPrefix(mimeType, "audio/") { - audio = value - } - } - } - continue - } - if !isPublicMediaURL(rawURL) { - continue - } - var data []byte - var mimeType string - var err error - if policy == nil { - data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL) - } else { - data, mimeType, err = runVideoDownload(ctx, taskID, *policy, func(ctx context.Context) ([]byte, string, error) { - return getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL) - }) - } - if err != nil { - return nil, fmt.Errorf("下载 RunningHub 产物失败:%w", err) - } - mimeType = runningHubOutputMimeType(rawURL, mimeType) - item := workflowOutputValue(mimeType, data) - if item == nil { - continue - } - value := item.(map[string]interface{}) - switch { - case strings.HasPrefix(mimeType, "image/"): - images = append(images, value) - case strings.HasPrefix(mimeType, "video/"): - video = value - case strings.HasPrefix(mimeType, "audio/"): - audio = value - } - } - result := map[string]interface{}{"mode": "image"} - if len(images) > 0 { - result["images"] = images - } - if video != nil { - result["mode"] = "video" - result["video"] = video - } - if audio != nil { - result["mode"] = "audio" - result["audio"] = audio - } - if len(images) == 0 && video == nil && audio == nil { - return nil, errors.New("RunningHub 返回的产物类型不受支持") - } - return result, nil -} - -func runningHubOutputMimeType(rawURL string, declared string) string { - declared = strings.TrimSpace(strings.Split(declared, ";")[0]) - if declared != "" && declared != "application/octet-stream" { - return declared - } - pathValue := rawURL - if parsed, err := url.Parse(rawURL); err == nil && parsed.Path != "" { - pathValue = parsed.Path - } - pathValue = strings.ToLower(strings.Split(pathValue, "?")[0]) - for suffix, mimeType := range map[string]string{ - ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", - ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/mp4", - ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac", - } { - if strings.HasSuffix(pathValue, suffix) { - return mimeType - } - } - return declared -} - -func workflowOutputValue(mimeType string, data []byte) interface{} { - mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) - if mimeType == "" { - mimeType = "application/octet-stream" - } - if len(data) == 0 { - return nil - } - return map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "bytes": len(data)} -} - -func runningHubRootURL(value string) string { - base := strings.TrimRight(strings.TrimSpace(value), "/") - if base == "" { - base = "https://www.runninghub.cn" - } - // 设置页可能保存根地址、/openapi/v2 或带尾部斜杠的任一形式;任务 - // OpenAPI 使用根地址下的 /task/openapi/*,统一剥掉 API 前缀。 - lower := strings.ToLower(base) - for _, suffix := range []string{"/openapi/v2", "/openapi"} { - if strings.HasSuffix(lower, suffix) { - base = base[:len(base)-len(suffix)] - break - } - } - return strings.TrimRight(base, "/") -} - -func resolveRunningHubOutputURL(root string, rawURL string) string { - rawURL = rewriteRunningHubOutputHost(strings.TrimSpace(rawURL)) - if rawURL == "" || strings.HasPrefix(rawURL, "data:") || isPublicMediaURL(rawURL) { - return rawURL - } - if strings.HasPrefix(rawURL, "//") { - return "https:" + rawURL - } - if strings.HasPrefix(rawURL, "/") { - return strings.TrimRight(root, "/") + rawURL - } - // API 可能返回 output/foo、assets/foo 这类无前导斜杠的相对路径。 - for _, prefix := range []string{"output/", "assets/", "input/"} { - if strings.HasPrefix(strings.ToLower(rawURL), prefix) { - return strings.TrimRight(root, "/") + "/" + rawURL - } - } - if runningHubRelativeOutputPath(rawURL) { - return strings.TrimRight(root, "/") + "/" + strings.TrimLeft(rawURL, "/") - } - return rawURL -} - -// RunningHub 某些区域会返回旧 COS 域名,参考项目已将其迁移到可访问域名; -// 这里只做固定 host 映射,不接受响应内容提供任意代理目标。 -func rewriteRunningHubOutputHost(rawURL string) string { - parsed, err := url.Parse(rawURL) - if err != nil || parsed.Host == "" { - return rawURL - } - if strings.EqualFold(parsed.Host, "rh-images-1252422369.cos.ap-beijing.myqcloud.com") { - parsed.Host = "rh-images.xiaoyaoyou.com" - return parsed.String() - } - return rawURL -} - -func runningHubTaskID(payload map[string]any) string { - for _, keys := range [][]string{{"data", "taskId"}, {"data", "task_id"}, {"data", "taskID"}, {"data", "id"}, {"taskId"}, {"task_id"}, {"taskID"}, {"id"}} { - if len(keys) == 2 { - if value := nestedString(payload, keys[0], keys[1]); value != "" { - return value - } - } else if value := stringValue(payload[keys[0]]); value != "" { - return value - } - } - return "" -} - -func runningHubOutputURLs(value interface{}) []string { - result := make([]string, 0) - var visit func(interface{}) - visit = func(current interface{}) { - switch item := current.(type) { - case string: - if strings.HasPrefix(item, "http://") || strings.HasPrefix(item, "https://") || strings.HasPrefix(item, "data:") || (strings.HasPrefix(item, "/") && !strings.HasPrefix(item, "//")) || runningHubRelativeOutputPath(item) { - result = append(result, item) - } - case []interface{}: - for _, child := range item { - visit(child) - } - case map[string]interface{}: - for key, child := range item { - lowerKey := strings.ToLower(strings.TrimSpace(key)) - if lowerKey == "fileurl" || lowerKey == "file_url" || lowerKey == "url" || lowerKey == "downloadurl" || lowerKey == "download_url" || lowerKey == "src" || lowerKey == "output" || lowerKey == "outputs" || lowerKey == "results" || lowerKey == "files" || lowerKey == "data" || lowerKey == "images" || lowerKey == "videos" || lowerKey == "audio" || lowerKey == "audios" || lowerKey == "result" { - visit(child) - } - } - } - } - visit(value) - seen := map[string]bool{} - deduped := make([]string, 0, len(result)) - for _, item := range result { - if !seen[item] { - seen[item] = true - deduped = append(deduped, item) - } - } - return deduped -} - -func runningHubRelativeOutputPath(value string) bool { - value = strings.ToLower(strings.TrimSpace(value)) - if value == "" || strings.Contains(value, "://") || strings.HasPrefix(value, "//") { - return false - } - for _, segment := range strings.Split(strings.Split(value, "?")[0], "/") { - if segment == ".." { - return false - } - } - for _, prefix := range []string{"output/", "assets/", "input/"} { - if strings.HasPrefix(value, prefix) { - return true - } - } - for _, suffix := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg", ".m4a", ".flac"} { - if strings.HasSuffix(strings.Split(value, "?")[0], suffix) { - return true - } - } - return false -} - -func runningHubFailureMessage(payload map[string]any) string { - for _, key := range []string{"msg", "message", "error", "failReason", "failedReason", "errorMessage"} { - if value := stringValue(payload[key]); value != "" { - return runningHubActionableFailureMessage(value) - } - } - if nested, ok := payload["data"].(map[string]interface{}); ok { - return runningHubFailureMessage(nested) - } - return "上游未提供失败原因" -} - -func isWorkflowSeedField(value string) bool { - normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) - return normalized == "seed" || strings.HasSuffix(normalized, "seed") || strings.Contains(normalized, "noiseseed") -} - -func runningHubActionableFailureMessage(message string) string { - normalized := strings.ToLower(message) - if strings.Contains(normalized, "node_info_mismatch") || strings.Contains(normalized, "node_not_found_in_workflow") { - return "RunningHub AI 应用的公开参数已变化,请到“设置 → RunningHub 工作流”重新选择该 App 并点击“拉取参数”后再试(上游详情:" + message + ")" - } - return message -} - -// runningHubPayloadCode 兼容 code/statusCode 以及部分网关返回的字符串状态。 -// 统一映射到 RunningHub 公开状态码:0 成功、804 运行中、813 排队中、805/806 失败。 -func runningHubPayloadCode(payload map[string]any) (int, bool) { - if payload == nil { - return 0, false - } - topCode, topValid := runningHubDirectCode(payload) - if nested, ok := payload["data"].(map[string]interface{}); ok { - if nestedCode, nestedValid := runningHubPayloadCode(nested); nestedValid { - // 有些网关把 HTTP 成功包装成顶层 code=0,同时把真实任务状态放在 - // data.code/status;真实任务状态优先,避免把排队任务误判成完成。 - if !topValid || topCode == 0 || nestedCode != 0 { - return nestedCode, true - } - } - } - return topCode, topValid -} - -func runningHubDirectCode(payload map[string]any) (int, bool) { - primaryCode := 0 - primaryValid := false - for _, key := range []string{"code", "statusCode", "status_code"} { - if value, ok := payload[key]; ok { - if code, valid := runningHubCode(value); valid { - if (key == "statusCode" || key == "status_code") && code >= 200 && code < 300 { - code = 0 - } - primaryCode, primaryValid = code, true - break - } - } - } - for _, key := range []string{"status", "state", "taskStatus", "task_status"} { - if code, valid := runningHubStatusCode(payload[key]); valid { - if !primaryValid || primaryCode == 0 || code != 0 { - return code, true - } - break - } - } - if primaryValid { - return primaryCode, true - } - // errorCode=0 通常只是“无错误”标记,不能覆盖 data.status=running。 - for _, key := range []string{"errorCode", "error_code"} { - if value, ok := payload[key]; ok { - if code, valid := runningHubCode(value); valid && code != 0 { - return code, true - } - } - } - return 0, false -} - -func runningHubStatusCode(value interface{}) (int, bool) { - text := strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) - if text == "" || text == "" { - return 0, false - } - if numeric, ok := runningHubCode(text); ok { - switch numeric { - case 0, 804, 813, 805, 806: - return numeric, true - } - } - text = strings.NewReplacer("_", "", "-", "", " ", "").Replace(text) - switch text { - case "success", "succeeded", "complete", "completed", "done", "finished", "finish", "3": - return 0, true - case "queued", "queue", "pending", "waiting", "created", "submitted": - return 813, true - case "running", "processing", "executing", "inprogress", "started", "working", "1", "2": - return 804, true - case "failed", "failure", "error", "rejected", "cancelled", "canceled", "expired", "aborted", "4", "5": - return 805, true - default: - return 0, false - } -} - -func nestedString(payload map[string]any, first string, second string) string { - nested, _ := payload[first].(map[string]interface{}) - return stringValue(nested[second]) -} - -func runningHubFileName(payload map[string]any) string { - for _, key := range []string{"fileName", "file_name", "filename", "name"} { - if value := stringValue(payload[key]); value != "" { - return value - } - } - for _, container := range []string{"data", "result", "file", "files"} { - if nested, ok := payload[container].(map[string]interface{}); ok { - for _, key := range []string{"fileName", "file_name", "filename", "name"} { - if value := stringValue(nested[key]); value != "" { - return value - } - } - } - } - return "" -} - -func runningHubCode(value interface{}) (int, bool) { - switch item := value.(type) { - case int: - if item < 0 { - return 0, false - } - return item, true - case int64: - if item < 0 { - return 0, false - } - return int(item), true - case float64: - code := int(item) - if item < 0 || float64(code) != item { - return 0, false - } - return code, true - case json.Number, string: - code := mapNumber(item) - return code, code >= 0 - default: - return 0, false - } -} - -func mapNumber(value interface{}) int { - switch item := value.(type) { - case int: - return item - case int64: - return int(item) - case float64: - return int(item) - case json.Number: - parsed, _ := strconv.Atoi(string(item)) - return parsed - case string: - parsed, _ := strconv.Atoi(strings.TrimSpace(item)) - return parsed - default: - return -1 - } -} diff --git a/backend/internal/app/workflow_provider_field.go b/backend/internal/app/workflow_provider_field.go new file mode 100644 index 000000000..03ce6e870 --- /dev/null +++ b/backend/internal/app/workflow_provider_field.go @@ -0,0 +1,284 @@ +// 工作流字段定义(WorkflowField)的兼容解析。 +// +// 字段 JSON 来自管理后台配置和历史数据,写法不统一(字符串/数字/嵌套对象都出现过); +// UnmarshalJSON 在这里统一容错,下游只面对规范化后的结构。 + +package app + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// WorkflowField 是云端工作流字段描述。Value 与 FieldValue 兼容来源项目 +// 的两种命名;Source 可取 referenceImage/referenceVideo/referenceAudio/mask。 +type WorkflowField struct { + ID string `json:"id"` + NodeID string `json:"nodeId"` + ClassType string `json:"classType,omitempty"` + FieldName string `json:"fieldName"` + Value interface{} `json:"value,omitempty"` + FieldValue interface{} `json:"fieldValue,omitempty"` + FieldType string `json:"fieldType,omitempty"` + Label string `json:"label,omitempty"` + Role string `json:"role,omitempty"` + SafeToOverride *bool `json:"safeToOverride,omitempty"` + OptionsSource string `json:"optionsSource,omitempty"` + Options []interface{} `json:"options,omitempty"` + Min interface{} `json:"min,omitempty"` + Max interface{} `json:"max,omitempty"` + Step interface{} `json:"step,omitempty"` + RandomEnabled bool `json:"randomEnabled,omitempty"` + BindPrompt bool `json:"bindPrompt,omitempty"` + // 指针用于区分“未配置(默认启用)”和明确传入 false。 + Enabled *bool `json:"enabled,omitempty"` + Source string `json:"source,omitempty"` + SourceIndex int `json:"sourceIndex,omitempty"` + ImageOrder int `json:"imageOrder,omitempty"` + SourceFromUpstream bool `json:"sourceFromUpstream,omitempty"` + Required bool `json:"required,omitempty"` + // nil 兼容旧配置;true 表示来源由字段名推断,false 表示用户在映射面板明确选择。 + SourceAutomatic *bool `json:"sourceAutomatic,omitempty"` + // 仅在本次反序列化期间保留,用于区分旧数据缺字段与用户明确选择“保留默认值”。 + sourceConfigured bool +} + +// UnmarshalJSON 兼容来源项目的字段配置格式。来源项目使用 node/input/default/ +// bind_prompt,而画布内部使用 nodeId/fieldName/fieldValue/source;在入口统一归一化, +// 后续 RunningHub 和 Bridge 不需要各自维护一套别名解析。 +func (f *WorkflowField) UnmarshalJSON(data []byte) error { + type plainWorkflowField WorkflowField + var parsed plainWorkflowField + if err := json.Unmarshal(data, &parsed); err != nil { + return err + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + *f = WorkflowField(parsed) + if f.NodeID == "" { + f.NodeID = workflowFieldRawString(raw, "node", "node_id", "nodeID") + } + if f.ClassType == "" { + f.ClassType = workflowFieldRawString(raw, "class_type", "typeName", "nodeType") + } + if f.FieldName == "" { + f.FieldName = workflowFieldRawString(raw, "input", "inputName", "input_name", "name") + } + if f.ID == "" { + f.ID = workflowFieldRawString(raw, "fieldId", "field_id", "key") + } + if f.FieldType == "" { + f.FieldType = workflowFieldRawString(raw, "type") + } + if len(f.Options) == 0 { + if options, ok := workflowFieldRawSlice(raw, "options", "values", "choices", "enum", "fieldOptions", "field_options"); ok { + f.Options = options + } + } + if f.Min == nil { + f.Min, _ = workflowFieldRawAny(raw, "min", "minValue", "min_value") + if f.Min == nil { + f.Min, _ = workflowFieldRawNestedAny(raw, "min", "minValue", "min_value") + } + } + if f.Max == nil { + f.Max, _ = workflowFieldRawAny(raw, "max", "maxValue", "max_value") + if f.Max == nil { + f.Max, _ = workflowFieldRawNestedAny(raw, "max", "maxValue", "max_value") + } + } + if f.Step == nil { + f.Step, _ = workflowFieldRawAny(raw, "step", "stepValue", "step_value") + if f.Step == nil { + f.Step, _ = workflowFieldRawNestedAny(raw, "step", "stepValue", "step_value") + } + } + if f.Label == "" { + f.Label = workflowFieldRawString(raw, "title") + } + sourceConfigured := workflowFieldRawHas(raw, "source") || workflowFieldRawHas(raw, "bind") || workflowFieldRawHas(raw, "from") + if f.Source == "" { + f.Source = workflowFieldRawString(raw, "bind", "from") + } + if !workflowFieldRawHas(raw, "fieldValue") { + if value, ok := workflowFieldRawAny(raw, "defaultValue", "default_value", "default"); ok { + f.FieldValue = value + } + } + if !workflowFieldRawHas(raw, "sourceIndex") { + f.SourceIndex = workflowFieldRawInt(raw, f.SourceIndex, "source_index", "index") + } + if !workflowFieldRawHas(raw, "imageOrder") { + f.ImageOrder = workflowFieldRawInt(raw, f.ImageOrder, "image_order") + } + if !workflowFieldRawHas(raw, "required") { + f.Required = workflowFieldRawBool(raw, f.Required, "isRequired", "is_required") + } + if !workflowFieldRawHas(raw, "randomEnabled") { + f.RandomEnabled = workflowFieldRawBool(raw, f.RandomEnabled, "random_enabled") + } + if !workflowFieldRawHas(raw, "bindPrompt") { + f.BindPrompt = workflowFieldRawBool(raw, f.BindPrompt, "bind_prompt") + } + if f.BindPrompt && strings.TrimSpace(f.Source) == "" && !sourceConfigured { + f.Source = "prompt" + } + sourceFromUpstreamConfigured := workflowFieldRawHas(raw, "sourceFromUpstream") || workflowFieldRawHas(raw, "source_from_upstream") + f.sourceConfigured = sourceConfigured || sourceFromUpstreamConfigured + if !workflowFieldRawHas(raw, "sourceFromUpstream") && workflowFieldRawHas(raw, "source_from_upstream") { + f.SourceFromUpstream = workflowFieldRawBool(raw, f.SourceFromUpstream, "source_from_upstream") + } + if !sourceConfigured && !sourceFromUpstreamConfigured && strings.TrimSpace(f.Source) == "" { + // 旧工作流没有保存动态来源时,按字段语义恢复宽高、数量、媒体等画布输入绑定。 + f.Source = workflowDynamicSource(f.FieldName, f.FieldType) + } + if !sourceConfigured && !sourceFromUpstreamConfigured && !f.SourceFromUpstream { + switch strings.ToLower(strings.TrimSpace(f.FieldType)) { + case "image", "video", "audio": + // 来源配置默认把媒体字段绑定到上游参考素材。 + f.SourceFromUpstream = true + } + } + if f.ID == "" && f.NodeID != "" && f.FieldName != "" { + f.ID = f.NodeID + "::" + f.FieldName + } + return nil +} + +func workflowFieldRawHas(raw map[string]json.RawMessage, key string) bool { + _, ok := raw[key] + return ok +} + +func workflowFieldRawString(raw map[string]json.RawMessage, keys ...string) string { + for _, key := range keys { + value, ok := raw[key] + if !ok { + continue + } + var text string + if json.Unmarshal(value, &text) == nil && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + var generic interface{} + if json.Unmarshal(value, &generic) == nil && generic != nil { + text = strings.TrimSpace(fmt.Sprint(generic)) + if text != "" && text != "" { + return text + } + } + } + return "" +} + +func workflowFieldRawAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) { + for _, key := range keys { + value, ok := raw[key] + if !ok || string(value) == "null" { + continue + } + var decoded interface{} + if json.Unmarshal(value, &decoded) == nil { + return decoded, true + } + } + return nil, false +} + +func workflowFieldRawSlice(raw map[string]json.RawMessage, keys ...string) ([]interface{}, bool) { + value, ok := workflowFieldRawAny(raw, keys...) + if !ok { + return nil, false + } + if items, ok := value.([]interface{}); ok { + return items, true + } + if object, ok := value.(map[string]interface{}); ok { + for _, key := range []string{"choices", "options", "values", "enum"} { + if items, ok := object[key].([]interface{}); ok { + return items, true + } + } + } + return nil, false +} + +func workflowFieldRawNestedAny(raw map[string]json.RawMessage, keys ...string) (interface{}, bool) { + for _, containerKey := range []string{"options", "values", "choices", "range", "fieldOptions", "field_options"} { + value, ok := workflowFieldRawAny(raw, containerKey) + if !ok { + continue + } + if found, ok := workflowNestedFieldValue(value, keys...); ok { + return found, true + } + } + return nil, false +} + +func workflowNestedFieldValue(value interface{}, keys ...string) (interface{}, bool) { + if items, ok := value.([]interface{}); ok { + for _, item := range items { + if found, ok := workflowNestedFieldValue(item, keys...); ok { + return found, true + } + } + return nil, false + } + object, ok := value.(map[string]interface{}) + if !ok { + return nil, false + } + for _, key := range keys { + if found, exists := object[key]; exists && found != nil { + return found, true + } + } + if nested, exists := object["range"]; exists { + return workflowNestedFieldValue(nested, keys...) + } + return nil, false +} + +func workflowFieldRawInt(raw map[string]json.RawMessage, fallback int, keys ...string) int { + value, ok := workflowFieldRawAny(raw, keys...) + if !ok { + return fallback + } + switch item := value.(type) { + case float64: + return int(item) + case json.Number: + parsed, err := strconv.Atoi(string(item)) + if err == nil { + return parsed + } + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(item)) + if err == nil { + return parsed + } + } + return fallback +} + +func workflowFieldRawBool(raw map[string]json.RawMessage, fallback bool, keys ...string) bool { + value, ok := workflowFieldRawAny(raw, keys...) + if !ok { + return fallback + } + switch item := value.(type) { + case bool: + return item + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(item)) + if err == nil { + return parsed + } + } + return fallback +} diff --git a/backend/internal/app/workflow_provider_outputs.go b/backend/internal/app/workflow_provider_outputs.go new file mode 100644 index 000000000..9ce1ac806 --- /dev/null +++ b/backend/internal/app/workflow_provider_outputs.go @@ -0,0 +1,553 @@ +// RunningHub 工作流的结果轮询、输出下载与错误码解析。 +// +// 上游输出地址可能是相对路径或内网主机,统一改写到配置的根地址后再下载; +// 失败信息尽量翻译成用户可操作的提示(额度不足、队列满、参数非法等)。 + +package app + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +func (s *Service) runningHubJSON(ctx context.Context, config providerConfig, endpoint string, body interface{}, target *map[string]any) error { + encoded, err := json.Marshal(body) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(encoded)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + ApplyOutboundHeaders(req, config.Headers) + data, _, err := doBinary(req) + if err != nil { + return err + } + if err := json.Unmarshal(data, target); err != nil { + return err + } + return nil +} + +func (s *Service) pollRunningHubWorkflow(ctx context.Context, config providerConfig, root string, taskID string, mode string) (map[string]interface{}, error) { + if mode == "video" { + return s.pollRunningHubVideoWorkflowWithPolicy(ctx, config, root, taskID, defaultVideoPollPolicy()) + } + return s.pollRunningHubWorkflowLegacy(ctx, config, root, taskID) +} + +func (s *Service) pollRunningHubVideoWorkflowWithPolicy(ctx context.Context, config providerConfig, root string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) { + return runVideoPollLoop(ctx, taskID, policy, func(ctx context.Context) (videoPollOutcome, error) { + var response map[string]any + err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response) + if err != nil { + return videoPollOutcome{}, fmt.Errorf("RunningHub 查询任务失败:%w", err) + } + code, validCode := runningHubPayloadCode(response) + if !validCode { + validCode = len(runningHubOutputURLs(response["data"])) > 0 + code = 0 + } + if !validCode { + return videoPollOutcome{}, errors.New("RunningHub 查询响应缺少可识别状态") + } + if code == 0 { + urls := runningHubOutputURLs(response["data"]) + if len(urls) == 0 { + return videoPollOutcome{}, errors.New("RunningHub 任务成功但没有返回产物") + } + for index, rawURL := range urls { + urls[index] = resolveRunningHubOutputURL(root, rawURL) + } + result, err := s.downloadWorkflowVideoOutputs(ctx, urls, taskID, policy) + if err != nil { + return videoPollOutcome{}, err + } + _ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil) + return videoPollOutcome{Done: true, Result: result}, nil + } + if code == 805 || code == 806 { + return videoPollOutcome{}, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response)) + } + stage := "running" + if code == 813 { + stage = "queued" + } else if code != 804 { + stage = "pending" + } + next := time.Now().Add(policy.Interval) + _ = s.updateWorkflowProviderState(ctx, taskID, stage, &next) + return videoPollOutcome{}, nil + }) +} + +func (s *Service) pollRunningHubWorkflowLegacy(ctx context.Context, config providerConfig, root string, taskID string) (map[string]interface{}, error) { + for deadline := providerPollingDeadline(ctx); time.Now().Before(deadline); { + var response map[string]any + err := s.runningHubJSON(withProviderRequestKind(ctx, "poll"), config, root+"/task/openapi/outputs", map[string]any{"apiKey": runningHubAPIKey(config), "taskId": taskID}, &response) + if err != nil { + return nil, fmt.Errorf("RunningHub 查询任务失败:%w", err) + } + code, validCode := runningHubPayloadCode(response) + if !validCode { + // 成功响应有时只有 outputs/results,没有显式状态码。 + validCode = len(runningHubOutputURLs(response["data"])) > 0 + code = 0 + } + if !validCode { + return nil, errors.New("RunningHub 查询响应缺少可识别状态") + } + if code == 0 { + urls := runningHubOutputURLs(response["data"]) + if len(urls) == 0 { + return nil, errors.New("RunningHub 任务成功但没有返回产物") + } + for index, rawURL := range urls { + urls[index] = resolveRunningHubOutputURL(root, rawURL) + } + _ = s.updateWorkflowProviderState(ctx, taskID, "succeeded", nil) + return s.downloadWorkflowOutputs(ctx, urls) + } + if code == 805 || code == 806 { + return nil, fmt.Errorf("RunningHub 任务失败:%s", runningHubFailureMessage(response)) + } + stage := "running" + if code == 813 { + stage = "queued" + } else if code != 804 { + // RunningHub 会扩展暂态码;只对明确失败码结束任务,其余状态继续轮询到终态或超时。 + stage = "pending" + } + _ = s.updateWorkflowProviderState(ctx, taskID, stage, ptr(time.Now().Add(2500*time.Millisecond))) + if err := sleepContext(ctx, 2500*time.Millisecond); err != nil { + return nil, err + } + } + return nil, fmt.Errorf("RunningHub 任务超时(%s)", taskID) +} + +// RunningHub 工作流的管理、提交和轮询固定使用积分 API Key;企业级 Key 仅由上传接口单独读取。 +func runningHubAPIKey(config providerConfig) string { + return strings.TrimSpace(config.APIKey) +} + +func (s *Service) downloadWorkflowOutputs(ctx context.Context, urls []string) (map[string]interface{}, error) { + return s.downloadWorkflowOutputsWithPolicy(ctx, urls, "", nil) +} + +func (s *Service) downloadWorkflowVideoOutputs(ctx context.Context, urls []string, taskID string, policy videoPollPolicy) (map[string]interface{}, error) { + return s.downloadWorkflowOutputsWithPolicy(ctx, urls, taskID, &policy) +} + +func (s *Service) downloadWorkflowOutputsWithPolicy(ctx context.Context, urls []string, taskID string, policy *videoPollPolicy) (map[string]interface{}, error) { + images := make([]map[string]interface{}, 0) + var video, audio map[string]interface{} + for _, rawURL := range urls { + if strings.HasPrefix(rawURL, "data:") { + mimeType, data, err := decodeProviderDataURL(rawURL) + if err != nil { + return nil, err + } + if item := workflowOutputValue(mimeType, data); item != nil { + switch value := item.(type) { + case map[string]interface{}: + if strings.HasPrefix(mimeType, "image/") { + images = append(images, value) + } else if strings.HasPrefix(mimeType, "video/") { + video = value + } else if strings.HasPrefix(mimeType, "audio/") { + audio = value + } + } + } + continue + } + if !isPublicMediaURL(rawURL) { + continue + } + var data []byte + var mimeType string + var err error + if policy == nil { + data, mimeType, err = getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL) + } else { + data, mimeType, err = runVideoDownload(ctx, taskID, *policy, func(ctx context.Context) ([]byte, string, error) { + return getExternalBinary(withProviderRequestKind(ctx, "download"), rawURL) + }) + } + if err != nil { + return nil, fmt.Errorf("下载 RunningHub 产物失败:%w", err) + } + mimeType = runningHubOutputMimeType(rawURL, mimeType) + item := workflowOutputValue(mimeType, data) + if item == nil { + continue + } + value := item.(map[string]interface{}) + switch { + case strings.HasPrefix(mimeType, "image/"): + images = append(images, value) + case strings.HasPrefix(mimeType, "video/"): + video = value + case strings.HasPrefix(mimeType, "audio/"): + audio = value + } + } + result := map[string]interface{}{"mode": "image"} + if len(images) > 0 { + result["images"] = images + } + if video != nil { + result["mode"] = "video" + result["video"] = video + } + if audio != nil { + result["mode"] = "audio" + result["audio"] = audio + } + if len(images) == 0 && video == nil && audio == nil { + return nil, errors.New("RunningHub 返回的产物类型不受支持") + } + return result, nil +} + +func runningHubOutputMimeType(rawURL string, declared string) string { + declared = strings.TrimSpace(strings.Split(declared, ";")[0]) + if declared != "" && declared != "application/octet-stream" { + return declared + } + pathValue := rawURL + if parsed, err := url.Parse(rawURL); err == nil && parsed.Path != "" { + pathValue = parsed.Path + } + pathValue = strings.ToLower(strings.Split(pathValue, "?")[0]) + for suffix, mimeType := range map[string]string{ + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", + ".mp4": "video/mp4", ".webm": "video/webm", ".mov": "video/quicktime", ".m4v": "video/mp4", + ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg", ".m4a": "audio/mp4", ".flac": "audio/flac", + } { + if strings.HasSuffix(pathValue, suffix) { + return mimeType + } + } + return declared +} + +func workflowOutputValue(mimeType string, data []byte) interface{} { + mimeType = strings.TrimSpace(strings.Split(mimeType, ";")[0]) + if mimeType == "" { + mimeType = "application/octet-stream" + } + if len(data) == 0 { + return nil + } + return map[string]interface{}{"dataUrl": dataURL(mimeType, data), "mimeType": mimeType, "bytes": len(data)} +} + +func runningHubRootURL(value string) string { + base := strings.TrimRight(strings.TrimSpace(value), "/") + if base == "" { + base = "https://www.runninghub.cn" + } + // 设置页可能保存根地址、/openapi/v2 或带尾部斜杠的任一形式;任务 + // OpenAPI 使用根地址下的 /task/openapi/*,统一剥掉 API 前缀。 + lower := strings.ToLower(base) + for _, suffix := range []string{"/openapi/v2", "/openapi"} { + if strings.HasSuffix(lower, suffix) { + base = base[:len(base)-len(suffix)] + break + } + } + return strings.TrimRight(base, "/") +} + +func resolveRunningHubOutputURL(root string, rawURL string) string { + rawURL = rewriteRunningHubOutputHost(strings.TrimSpace(rawURL)) + if rawURL == "" || strings.HasPrefix(rawURL, "data:") || isPublicMediaURL(rawURL) { + return rawURL + } + if strings.HasPrefix(rawURL, "//") { + return "https:" + rawURL + } + if strings.HasPrefix(rawURL, "/") { + return strings.TrimRight(root, "/") + rawURL + } + // API 可能返回 output/foo、assets/foo 这类无前导斜杠的相对路径。 + for _, prefix := range []string{"output/", "assets/", "input/"} { + if strings.HasPrefix(strings.ToLower(rawURL), prefix) { + return strings.TrimRight(root, "/") + "/" + rawURL + } + } + if runningHubRelativeOutputPath(rawURL) { + return strings.TrimRight(root, "/") + "/" + strings.TrimLeft(rawURL, "/") + } + return rawURL +} + +// RunningHub 某些区域会返回旧 COS 域名,参考项目已将其迁移到可访问域名; +// 这里只做固定 host 映射,不接受响应内容提供任意代理目标。 +func rewriteRunningHubOutputHost(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Host == "" { + return rawURL + } + if strings.EqualFold(parsed.Host, "rh-images-1252422369.cos.ap-beijing.myqcloud.com") { + parsed.Host = "rh-images.xiaoyaoyou.com" + return parsed.String() + } + return rawURL +} + +func runningHubTaskID(payload map[string]any) string { + for _, keys := range [][]string{{"data", "taskId"}, {"data", "task_id"}, {"data", "taskID"}, {"data", "id"}, {"taskId"}, {"task_id"}, {"taskID"}, {"id"}} { + if len(keys) == 2 { + if value := nestedString(payload, keys[0], keys[1]); value != "" { + return value + } + } else if value := stringValue(payload[keys[0]]); value != "" { + return value + } + } + return "" +} + +func runningHubOutputURLs(value interface{}) []string { + result := make([]string, 0) + var visit func(interface{}) + visit = func(current interface{}) { + switch item := current.(type) { + case string: + if strings.HasPrefix(item, "http://") || strings.HasPrefix(item, "https://") || strings.HasPrefix(item, "data:") || (strings.HasPrefix(item, "/") && !strings.HasPrefix(item, "//")) || runningHubRelativeOutputPath(item) { + result = append(result, item) + } + case []interface{}: + for _, child := range item { + visit(child) + } + case map[string]interface{}: + for key, child := range item { + lowerKey := strings.ToLower(strings.TrimSpace(key)) + if lowerKey == "fileurl" || lowerKey == "file_url" || lowerKey == "url" || lowerKey == "downloadurl" || lowerKey == "download_url" || lowerKey == "src" || lowerKey == "output" || lowerKey == "outputs" || lowerKey == "results" || lowerKey == "files" || lowerKey == "data" || lowerKey == "images" || lowerKey == "videos" || lowerKey == "audio" || lowerKey == "audios" || lowerKey == "result" { + visit(child) + } + } + } + } + visit(value) + seen := map[string]bool{} + deduped := make([]string, 0, len(result)) + for _, item := range result { + if !seen[item] { + seen[item] = true + deduped = append(deduped, item) + } + } + return deduped +} + +func runningHubRelativeOutputPath(value string) bool { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" || strings.Contains(value, "://") || strings.HasPrefix(value, "//") { + return false + } + for _, segment := range strings.Split(strings.Split(value, "?")[0], "/") { + if segment == ".." { + return false + } + } + for _, prefix := range []string{"output/", "assets/", "input/"} { + if strings.HasPrefix(value, prefix) { + return true + } + } + for _, suffix := range []string{".png", ".jpg", ".jpeg", ".webp", ".gif", ".mp4", ".webm", ".mov", ".m4v", ".mp3", ".wav", ".ogg", ".m4a", ".flac"} { + if strings.HasSuffix(strings.Split(value, "?")[0], suffix) { + return true + } + } + return false +} + +func runningHubFailureMessage(payload map[string]any) string { + for _, key := range []string{"msg", "message", "error", "failReason", "failedReason", "errorMessage"} { + if value := stringValue(payload[key]); value != "" { + return runningHubActionableFailureMessage(value) + } + } + if nested, ok := payload["data"].(map[string]interface{}); ok { + return runningHubFailureMessage(nested) + } + return "上游未提供失败原因" +} + +func isWorkflowSeedField(value string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) + return normalized == "seed" || strings.HasSuffix(normalized, "seed") || strings.Contains(normalized, "noiseseed") +} + +func runningHubActionableFailureMessage(message string) string { + normalized := strings.ToLower(message) + if strings.Contains(normalized, "node_info_mismatch") || strings.Contains(normalized, "node_not_found_in_workflow") { + return "RunningHub AI 应用的公开参数已变化,请到“设置 → RunningHub 工作流”重新选择该 App 并点击“拉取参数”后再试(上游详情:" + message + ")" + } + return message +} + +// runningHubPayloadCode 兼容 code/statusCode 以及部分网关返回的字符串状态。 +// 统一映射到 RunningHub 公开状态码:0 成功、804 运行中、813 排队中、805/806 失败。 +func runningHubPayloadCode(payload map[string]any) (int, bool) { + if payload == nil { + return 0, false + } + topCode, topValid := runningHubDirectCode(payload) + if nested, ok := payload["data"].(map[string]interface{}); ok { + if nestedCode, nestedValid := runningHubPayloadCode(nested); nestedValid { + // 有些网关把 HTTP 成功包装成顶层 code=0,同时把真实任务状态放在 + // data.code/status;真实任务状态优先,避免把排队任务误判成完成。 + if !topValid || topCode == 0 || nestedCode != 0 { + return nestedCode, true + } + } + } + return topCode, topValid +} + +func runningHubDirectCode(payload map[string]any) (int, bool) { + primaryCode := 0 + primaryValid := false + for _, key := range []string{"code", "statusCode", "status_code"} { + if value, ok := payload[key]; ok { + if code, valid := runningHubCode(value); valid { + if (key == "statusCode" || key == "status_code") && code >= 200 && code < 300 { + code = 0 + } + primaryCode, primaryValid = code, true + break + } + } + } + for _, key := range []string{"status", "state", "taskStatus", "task_status"} { + if code, valid := runningHubStatusCode(payload[key]); valid { + if !primaryValid || primaryCode == 0 || code != 0 { + return code, true + } + break + } + } + if primaryValid { + return primaryCode, true + } + // errorCode=0 通常只是“无错误”标记,不能覆盖 data.status=running。 + for _, key := range []string{"errorCode", "error_code"} { + if value, ok := payload[key]; ok { + if code, valid := runningHubCode(value); valid && code != 0 { + return code, true + } + } + } + return 0, false +} + +func runningHubStatusCode(value interface{}) (int, bool) { + text := strings.ToLower(strings.TrimSpace(fmt.Sprint(value))) + if text == "" || text == "" { + return 0, false + } + if numeric, ok := runningHubCode(text); ok { + switch numeric { + case 0, 804, 813, 805, 806: + return numeric, true + } + } + text = strings.NewReplacer("_", "", "-", "", " ", "").Replace(text) + switch text { + case "success", "succeeded", "complete", "completed", "done", "finished", "finish", "3": + return 0, true + case "queued", "queue", "pending", "waiting", "created", "submitted": + return 813, true + case "running", "processing", "executing", "inprogress", "started", "working", "1", "2": + return 804, true + case "failed", "failure", "error", "rejected", "cancelled", "canceled", "expired", "aborted", "4", "5": + return 805, true + default: + return 0, false + } +} + +func nestedString(payload map[string]any, first string, second string) string { + nested, _ := payload[first].(map[string]interface{}) + return stringValue(nested[second]) +} + +func runningHubFileName(payload map[string]any) string { + for _, key := range []string{"fileName", "file_name", "filename", "name"} { + if value := stringValue(payload[key]); value != "" { + return value + } + } + for _, container := range []string{"data", "result", "file", "files"} { + if nested, ok := payload[container].(map[string]interface{}); ok { + for _, key := range []string{"fileName", "file_name", "filename", "name"} { + if value := stringValue(nested[key]); value != "" { + return value + } + } + } + } + return "" +} + +func runningHubCode(value interface{}) (int, bool) { + switch item := value.(type) { + case int: + if item < 0 { + return 0, false + } + return item, true + case int64: + if item < 0 { + return 0, false + } + return int(item), true + case float64: + code := int(item) + if item < 0 || float64(code) != item { + return 0, false + } + return code, true + case json.Number, string: + code := mapNumber(item) + return code, code >= 0 + default: + return 0, false + } +} + +func mapNumber(value interface{}) int { + switch item := value.(type) { + case int: + return item + case int64: + return int(item) + case float64: + return int(item) + case json.Number: + parsed, _ := strconv.Atoi(string(item)) + return parsed + case string: + parsed, _ := strconv.Atoi(strings.TrimSpace(item)) + return parsed + default: + return -1 + } +} diff --git a/backend/internal/app/workflow_provider_values.go b/backend/internal/app/workflow_provider_values.go new file mode 100644 index 000000000..09d464902 --- /dev/null +++ b/backend/internal/app/workflow_provider_values.go @@ -0,0 +1,1055 @@ +// 工作流字段取值:把用户在画布上的选择(尺寸、比例、时长、分辨率、提示词、种子) +// 解析成 RunningHub 节点可接受的具体值。 +// +// 规则:显式值优先;其次按字段声明的选项/范围就近取值;都没有才用配置默认值。 +// 尺寸类字段会按上游要求的步长取整,避免上游拒绝非法分辨率。 + +package app + +import ( + cryptorand "crypto/rand" + "encoding/json" + "errors" + "fmt" + "math" + "math/big" + "sort" + "strconv" + "strings" +) + +func workflowFieldsFromManagement(workflow map[string]interface{}, mode string) ([]WorkflowField, error) { + rawFields := collectManagementWorkflowFields(workflow, mode) + encoded, err := json.Marshal(rawFields) + if err != nil { + return nil, fmt.Errorf("工作流字段映射编码失败:%w", err) + } + var fields []WorkflowField + if err := json.Unmarshal(encoded, &fields); err != nil { + return nil, fmt.Errorf("工作流字段映射解析失败:%w", err) + } + return fields, nil +} + +func workflowFieldsForMode(fields []WorkflowField, mode string) []WorkflowField { + if len(fields) == 0 { + return fields + } + normalized := make([]WorkflowField, len(fields)) + copy(normalized, fields) + for index := range normalized { + field := &normalized[index] + if canonical := workflowNamedDynamicSource(field.Source, mode); canonical != "" { + field.Source = canonical + } + inferred := workflowDynamicSourceForMode(field.FieldName, field.FieldType, mode) + if strings.TrimSpace(field.Source) == "" && !field.sourceConfigured && !field.BindPrompt { + field.Source = inferred + if inferred != "" { + field.SourceAutomatic = ptr(true) + } + continue + } + if shouldRepairLegacyWorkflowSource(field.FieldName, field.Source, inferred, mode) { + field.Source = inferred + } + } + return normalized +} + +func shouldRepairLegacyWorkflowSource(fieldName string, source string, inferred string, mode string) bool { + sourceKey := normalizeManagementFieldName(source) + inferredKey := normalizeManagementFieldName(inferred) + if sourceKey == "" || inferredKey == "" || sourceKey == inferredKey { + return false + } + fieldKey := normalizeManagementFieldName(fieldName) + wasMistakenForMedia := sourceKey == "referenceimage" || sourceKey == "referencevideo" || sourceKey == "referenceaudio" + if wasMistakenForMedia && (workflowDimensionNameSource(fieldKey) != "" || inferredKey == "aspectratio" || inferredKey == "vquality") { + return true + } + mode = strings.ToLower(strings.TrimSpace(mode)) + return (fieldKey == "resolution" && (mode == "video" && sourceKey == "size" || mode == "image" && sourceKey == "vquality")) || (fieldKey == "quality" && sourceKey == "vquality") +} + +func validateWorkflowMediaInputs(fields []WorkflowField, input canvasGenerationInput) error { + capacities := map[string]int{"referenceimage": 0, "referencevideo": 0, "referenceaudio": 0} + hasMaskMapping := false + for _, field := range fields { + if field.Enabled != nil && !*field.Enabled { + continue + } + source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") + if source == "mask" { + hasMaskMapping = true + continue + } + key := "" + switch source { + case "referenceimage", "image", "referenceimages": + key = "referenceimage" + case "referencevideo", "video", "referencevideos": + key = "referencevideo" + case "referenceaudio", "audio", "referenceaudios": + key = "referenceaudio" + } + if key == "" { + continue + } + index := field.SourceIndex + if field.ImageOrder > 0 { + index = field.ImageOrder - 1 + } + if index < 0 { + index = 0 + } + if index+1 > capacities[key] { + capacities[key] = index + 1 + } + } + checks := []struct { + label string + count int + capacity int + }{ + {label: "参考图片", count: len(input.ReferenceImages), capacity: capacities["referenceimage"]}, + {label: "参考视频", count: len(input.ReferenceVideos), capacity: capacities["referencevideo"]}, + {label: "参考音频", count: len(input.ReferenceAudios), capacity: capacities["referenceaudio"]}, + } + for _, check := range checks { + if check.count > check.capacity { + return fmt.Errorf("工作流只配置了 %d 个%s槽位,但画布传入了 %d 个;请在工作流字段映射中补齐槽位", check.capacity, check.label, check.count) + } + } + if input.Mask != nil && !hasMaskMapping { + return errors.New("画布传入了蒙版,但工作流没有配置蒙版字段映射") + } + return nil +} + +func runningHubNodeInfo(fields []WorkflowField, files map[string]string, input canvasGenerationInput) ([]map[string]any, error) { + return runningHubNodeInfoWithWorkflow(fields, files, input, nil) +} + +func runningHubNodeInfoWithWorkflow(fields []WorkflowField, files map[string]string, input canvasGenerationInput, workflow map[string]interface{}) ([]map[string]any, error) { + items := make([]map[string]any, 0, len(fields)+3) + for _, field := range fields { + if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { + continue + } + // RunningHub 的 nodeInfoList 只接受字符串 fieldValue。部分内部节点不会把 + // 字符串恢复为数值,因此安全边界必须由服务端强制执行,不能依赖前端隐藏。 + if isRunningHubUnsafeInternalField(workflow, field) { + continue + } + value, present, err := resolveWorkflowFieldValue(field, files, input) + if err != nil { + return nil, err + } + if !present { + if field.Required { + return nil, fmt.Errorf("工作流字段 %s 缺少值", firstNonEmptyString(field.ID, field.FieldName)) + } + continue + } + if err := validateRunningHubWorkflowFieldValue(field, value); err != nil { + return nil, err + } + encoded := workflowScalarString(value) + if !shouldSendRunningHubWorkflowField(workflow, field, encoded) { + continue + } + items = append(items, map[string]any{"nodeId": field.NodeID, "fieldName": field.FieldName, "fieldValue": encoded}) + } + return items, nil +} + +func shouldSendRunningHubWorkflowField(workflow map[string]interface{}, field WorkflowField, encoded string) bool { + if len(workflow) == 0 { + // AI App 只有公开参数列表,没有可用于比较的工作流 JSON。 + return true + } + node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}) + if !ok { + return true + } + inputs, ok := node["inputs"].(map[string]interface{}) + if !ok { + return true + } + original, exists := inputs[strings.TrimSpace(field.FieldName)] + if exists && isWorkflowLinkValue(original) { + // 已连接输入属于工作流拓扑,不能被旧字段映射拆成字符串覆盖。 + return false + } + if normalizeWorkflowFieldSource(field) != "" || field.RandomEnabled { + return !isRunningHubAutomaticInternalBinding(node, field) + } + if !exists { + return true + } + // 拉取参数时会保存所有 widget 的默认值;未修改项无需重复覆盖工作流。 + return encoded != workflowScalarString(original) +} + +func isRunningHubAutomaticInternalBinding(node map[string]interface{}, field WorkflowField) bool { + if field.SourceAutomatic != nil && !*field.SourceAutomatic { + return false + } + classType := strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) + if classType != "imageresize+" { + return false + } + // 旧配置没有 sourceAutomatic,但 ImageResize+ 的宽高来源是按同名字段自动误判的。 + // 保留节点自身尺寸链;用户仍可将来源设为默认并明确修改静态值。 + source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") + return source == "size" || source == "imagesize" || source == "sizewidth" || source == "width" || source == "imagewidth" || source == "videowidth" || source == "sizeheight" || source == "height" || source == "imageheight" || source == "videoheight" +} + +func isRunningHubUnsafeInternalField(workflow map[string]interface{}, field WorkflowField) bool { + if field.SafeToOverride != nil && !*field.SafeToOverride { + return true + } + classType := strings.ToLower(strings.TrimSpace(field.ClassType)) + if node, ok := workflow[strings.TrimSpace(field.NodeID)].(map[string]interface{}); ok { + classType = strings.ToLower(strings.TrimSpace(stringValue(node["class_type"]))) + } + fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) + if classType == "int" && fieldName == "value" { + return true + } + return classType == "imageresize+" && (fieldName == "width" || fieldName == "height" || fieldName == "multiple_of") +} + +func validateRunningHubWorkflowFieldValue(field WorkflowField, value interface{}) error { + fieldID := firstNonEmptyString(strings.TrimSpace(field.ID), strings.TrimSpace(field.NodeID)+"."+strings.TrimSpace(field.FieldName)) + fieldType := strings.ToUpper(strings.TrimSpace(field.FieldType)) + if fieldType == "NUMBER" || fieldType == "FLOAT" || fieldType == "INTEGER" || fieldType == "INT" || fieldType == "SLIDER" { + numeric, ok := workflowNumericBound(value) + if !ok { + return fmt.Errorf("工作流字段 %s 不是有效数字", fieldID) + } + minValue, hasMin := workflowNumericBound(field.Min) + maxValue, hasMax := workflowNumericBound(field.Max) + stepValue, hasStep := workflowNumericBound(field.Step) + if hasMin && hasMax && minValue > maxValue { + return fmt.Errorf("工作流字段 %s 的最小值不能大于最大值", fieldID) + } + if hasStep && stepValue <= 0 { + return fmt.Errorf("工作流字段 %s 的步长必须大于 0", fieldID) + } + if (hasMin && numeric < minValue) || (hasMax && numeric > maxValue) { + return fmt.Errorf("工作流字段 %s 的值超出允许范围", fieldID) + } + if hasStep { + start := float64(0) + if hasMin { + start = minValue + } + steps := (numeric - start) / stepValue + if math.Abs(steps-math.Round(steps)) > 1e-7 { + return fmt.Errorf("工作流字段 %s 的值不符合步长", fieldID) + } + } + } + if fieldType == "BOOLEAN" || fieldType == "BOOL" { + switch item := value.(type) { + case bool: + case string: + normalized := strings.ToLower(strings.TrimSpace(item)) + if normalized != "true" && normalized != "false" { + return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) + } + default: + return fmt.Errorf("工作流字段 %s 不是有效开关值", fieldID) + } + } + if options := workflowFieldAllowedOptions(field); len(options) > 0 { + encoded := strings.TrimSpace(workflowScalarString(value)) + matched := false + hasScalarOption := false + for _, option := range options { + if workflowOptionIsRange(option) { + continue + } + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate == "" || candidate == "" { + continue + } + hasScalarOption = true + if candidate == encoded { + matched = true + break + } + } + if hasScalarOption && !matched { + return fmt.Errorf("工作流字段 %s 的值不在允许选项中", fieldID) + } + } + return nil +} + +func workflowOptionIsRange(value interface{}) bool { + object, ok := value.(map[string]interface{}) + if !ok { + return false + } + for _, key := range []string{"min", "max", "step", "minValue", "maxValue", "stepValue"} { + if _, exists := object[key]; exists { + return true + } + } + if nested, ok := object["range"].(map[string]interface{}); ok { + return workflowOptionIsRange(nested) + } + return false +} + +func resolveWorkflowFieldValue(field WorkflowField, files map[string]string, input canvasGenerationInput) (interface{}, bool, error) { + source := normalizeWorkflowFieldSource(field) + // 画布参数按 nodeId + fieldName 独立保存时 source 可能为空;枚举字段仍必须 + // 使用工作流原始 options 的完整值,不能把用户界面上的比例简称直接发给 ComfyUI。 + if source == "" && strings.EqualFold(strings.TrimSpace(field.FieldName), "aspect_ratio") { + // 旧版前端只把比例写入 Config.Size,没有同步改写字段映射。此时不能 + // 直接使用工作流保存的默认值,否则用户选择 9:16 会在提交前静默回到 1:1。 + // 仅对非 1:1 的显式比例做回填,避免没有动态参数时覆盖工作流自己的 1:1 默认值。 + requested := strings.TrimSpace(input.Config.Size) + if requested != "" && !strings.EqualFold(workflowAspectRatio(requested), "1:1") { + value := workflowAspectRatioValue(field, requested) + if strings.TrimSpace(fmt.Sprint(value)) != "" { + return value, true, nil + } + } + raw := strings.TrimSpace(workflowOptionString(firstNonNilWorkflowValue(field.FieldValue, field.Value))) + if raw != "" { + value := workflowAspectRatioValue(field, raw) + return value, true, nil + } + } + if source != "" { + switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { + case "prompt", "text", "positiveprompt", "positive": + return input.Prompt, strings.TrimSpace(input.Prompt) != "", nil + case "size", "imagesize": + return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil + case "resolution": + if strings.EqualFold(strings.TrimSpace(input.Mode), "video") { + value := workflowVideoResolutionValue(field, input.Config.VQuality) + return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil + } + return input.Config.Size, strings.TrimSpace(input.Config.Size) != "", nil + case "aspectratio", "ratio", "imageaspectratio", "imageratio", "videoaspectratio", "videoratio": + value := workflowAspectRatioValue(field, input.Config.Size) + return value, value != "", nil + case "sizewidth", "width", "imagewidth", "videowidth": + value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 0) + return value, value != "", nil + case "sizeheight", "height", "imageheight", "videoheight": + value := workflowDimensionPart(input.Mode, input.Config.Size, input.Config.VQuality, 1) + return value, value != "", nil + case "quality": + return input.Config.Quality, strings.TrimSpace(input.Config.Quality) != "", nil + case "count", "batch", "batchsize": + return input.Config.Count, strings.TrimSpace(input.Config.Count) != "", nil + case "videoseconds", "video_seconds", "duration": + value := workflowVideoDurationValue(field, input.Config.VideoSeconds) + return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil + case "vquality", "videoquality", "video_quality": + value := workflowVideoResolutionValue(field, input.Config.VQuality) + return value, strings.TrimSpace(fmt.Sprint(value)) != "", nil + case "videogenerateaudio", "video_generate_audio", "generateaudio": + return input.Config.VideoGenerateAudio, strings.TrimSpace(input.Config.VideoGenerateAudio) != "", nil + case "videowatermark", "video_watermark", "watermark": + return input.Config.VideoWatermark, strings.TrimSpace(input.Config.VideoWatermark) != "", nil + case "audioformat", "audio_format": + return input.Config.AudioFormat, strings.TrimSpace(input.Config.AudioFormat) != "", nil + case "systemprompt", "system_prompt": + return input.Config.SystemPrompt, strings.TrimSpace(input.Config.SystemPrompt) != "", nil + case "transparentbackground", "transparent_background": + return input.Config.TransparentBackground, strings.TrimSpace(input.Config.TransparentBackground) != "", nil + case "audiovoice", "audio_voice", "voice": + return input.Config.AudioVoice, strings.TrimSpace(input.Config.AudioVoice) != "", nil + case "audiospeed", "audio_speed": + return input.Config.AudioSpeed, strings.TrimSpace(input.Config.AudioSpeed) != "", nil + case "audioinstructions", "audio_instructions": + return input.Config.AudioInstructions, strings.TrimSpace(input.Config.AudioInstructions) != "", nil + } + index := field.SourceIndex + if field.ImageOrder > 0 { + index = field.ImageOrder - 1 + } + var media providerMedia + switch strings.ReplaceAll(strings.ReplaceAll(source, "_", ""), "-", "") { + case "referenceimage", "image", "referenceimages": + if index < 0 || index >= len(input.ReferenceImages) { + return nil, false, nil + } + media = input.ReferenceImages[index] + case "referencevideo", "video", "referencevideos": + if index < 0 || index >= len(input.ReferenceVideos) { + return nil, false, nil + } + media = input.ReferenceVideos[index] + case "referenceaudio", "audio", "referenceaudios": + if index < 0 || index >= len(input.ReferenceAudios) { + return nil, false, nil + } + media = input.ReferenceAudios[index] + case "mask": + if input.Mask == nil { + return nil, false, nil + } + media = *input.Mask + default: + return nil, false, fmt.Errorf("不支持的工作流字段来源:%s", field.Source) + } + name := files[media.ID] + if name == "" { + return nil, false, errors.New("工作流参考素材尚未上传") + } + return name, true, nil + } + if field.RandomEnabled { + randomMax := field.Max + if isRunningHubInterface(input.Config.InterfaceType) && isWorkflowSeedField(field.FieldName) { + // RunningHub 的随机 Seed 按 uint32 传输,不能沿用 JavaScript 安全整数上限。 + const runningHubSeedMax int64 = 1<<32 - 1 + maxValue, err := workflowIntegerBound(field.Max, runningHubSeedMax) + if err != nil { + return nil, false, err + } + if maxValue > runningHubSeedMax { + maxValue = runningHubSeedMax + } + randomMax = maxValue + } + value, err := randomWorkflowInteger(field.Min, randomMax) + return value, err == nil, err + } + if field.FieldValue != nil { + return field.FieldValue, true, nil + } + if field.Value != nil { + return field.Value, true, nil + } + return nil, false, nil +} + +func firstNonNilWorkflowValue(values ...interface{}) interface{} { + for _, value := range values { + if value != nil { + return value + } + } + return nil +} + +func normalizeWorkflowFieldSource(field WorkflowField) string { + source := strings.ToLower(strings.TrimSpace(field.Source)) + if source == "" && field.BindPrompt { + return "prompt" + } + if source != "" { + return source + } + if !field.SourceFromUpstream { + return "" + } + fieldName := strings.ToLower(strings.TrimSpace(field.FieldName)) + if fieldName == "prompt" || fieldName == "text" || fieldName == "positive_prompt" || fieldName == "positiveprompt" { + return "prompt" + } + switch strings.ToLower(strings.TrimSpace(field.FieldType)) { + case "image", "img", "photo", "picture": + return "referenceimage" + case "video", "movie": + return "referencevideo" + case "audio", "sound", "music", "voice": + return "referenceaudio" + default: + return "" + } +} + +func workflowDimensionPart(mode string, size string, videoQuality string, index int) string { + if index < 0 || index > 1 { + return "" + } + if dimensions, ok := workflowPixelDimensions(size); ok { + return strconv.Itoa(dimensions[index]) + } + if strings.EqualFold(strings.TrimSpace(mode), "video") { + dimensions, ok := workflowVideoDimensions(size, videoQuality) + if !ok { + return "" + } + return strconv.Itoa(dimensions[index]) + } + dimensions, ok := workflowImageDimensions(size) + if !ok { + return "" + } + return strconv.Itoa(dimensions[index]) +} + +func workflowPixelDimensions(value string) ([2]int, bool) { + var result [2]int + normalized := strings.ToLower(strings.TrimSpace(value)) + if !strings.Contains(normalized, "x") { + return result, false + } + parts := strings.FieldsFunc(normalized, func(r rune) bool { return r == 'x' || r == ' ' || r == ',' }) + if len(parts) != 2 { + return result, false + } + for index, part := range parts { + parsed, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || parsed <= 0 { + return result, false + } + result[index] = parsed + } + return result, true +} + +func workflowAspectRatio(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + // RunningHub 的 ResolutionSelector 选项带有展示文案(例如 + // "9:16 (Portrait Widescreen)"),协议比较只需要比例前缀。 + if cut := strings.IndexAny(normalized, " ("); cut >= 0 { + normalized = strings.TrimSpace(normalized[:cut]) + } + for _, suffix := range []string{"-1k", "-2k", "-4k"} { + if strings.HasSuffix(normalized, suffix) { + normalized = strings.TrimSuffix(normalized, suffix) + break + } + } + if width, height, ok := workflowRatioParts(normalized); ok { + divisor := workflowGreatestCommonDivisor(width, height) + return fmt.Sprintf("%d:%d", width/divisor, height/divisor) + } + dimensions, ok := workflowPixelDimensions(normalized) + if !ok { + return "" + } + ratio := float64(dimensions[0]) / float64(dimensions[1]) + known := [][2]int{{1, 1}, {3, 2}, {2, 3}, {4, 3}, {3, 4}, {4, 5}, {5, 4}, {16, 9}, {9, 16}, {2, 1}, {1, 2}, {21, 9}} + bestDifference := math.MaxFloat64 + best := [2]int{} + for _, candidate := range known { + expected := float64(candidate[0]) / float64(candidate[1]) + difference := math.Abs(ratio-expected) / expected + if difference < bestDifference { + bestDifference = difference + best = candidate + } + } + if bestDifference <= 0.03 { + return fmt.Sprintf("%d:%d", best[0], best[1]) + } + divisor := workflowGreatestCommonDivisor(dimensions[0], dimensions[1]) + return fmt.Sprintf("%d:%d", dimensions[0]/divisor, dimensions[1]/divisor) +} + +func workflowAspectRatioValue(field WorkflowField, value string) string { + raw := strings.TrimSpace(value) + if options := workflowFieldAllowedOptions(field); len(options) > 0 { + requested := workflowAspectRatio(raw) + for _, option := range options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && strings.EqualFold(workflowAspectRatio(candidate), requested) { + return candidate + } + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" { + return fallback + } + return raw + } + // “auto/adaptive” 是工作流的真实模式,不应被画布当前像素尺寸反推成 + // 一个并不存在的比例;宽高字段仍按工作流自身的默认值或显式尺寸处理。 + if fallback := strings.TrimSpace(workflowFieldConfiguredDefault(field)); strings.EqualFold(fallback, "auto") || strings.EqualFold(fallback, "adaptive") { + return fallback + } + return workflowAspectRatio(raw) +} + +func workflowFieldAllowedOptions(field WorkflowField) []interface{} { + classType := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.ClassType), "_", ""), "-", "")) + fieldName := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(field.FieldName), "_", ""), "-", "")) + if classType == "resolutionselector" && fieldName == "aspectratio" { + // RunningHub 的工作流 API 不返回 object_info;ResolutionSelector 的完整枚举 + // 是节点协议的一部分,通用比例预设(9:16 等)不能直接提交给该节点。 + return []interface{}{ + "1:1 (Square)", + "2:3 (Portrait Photo)", + "3:2 (Photo)", + "3:4 (Portrait Standard)", + "4:3 (Standard)", + "9:16 (Portrait Widescreen)", + "16:9 (Widescreen)", + "21:9 (Ultrawide)", + } + } + return field.Options +} + +func workflowRatioParts(value string) (int, int, bool) { + parts := strings.Split(strings.TrimSpace(value), ":") + if len(parts) != 2 { + return 0, 0, false + } + width, widthErr := strconv.Atoi(strings.TrimSpace(parts[0])) + height, heightErr := strconv.Atoi(strings.TrimSpace(parts[1])) + return width, height, widthErr == nil && heightErr == nil && width > 0 && height > 0 +} + +func workflowGreatestCommonDivisor(left int, right int) int { + for right != 0 { + left, right = right, left%right + } + if left <= 0 { + return 1 + } + return left +} + +func workflowImageDimensions(value string) ([2]int, bool) { + normalized := strings.ToLower(strings.TrimSpace(value)) + preset := map[string][2]int{ + "1:1": {1024, 1024}, "3:2": {1536, 1024}, "2:3": {1024, 1536}, + "4:3": {1360, 1024}, "3:4": {1024, 1360}, "4:5": {1024, 1280}, "5:4": {1280, 1024}, + "16:9": {1824, 1024}, "9:16": {1024, 1824}, "2:1": {2048, 1024}, "1:2": {1024, 2048}, + "21:9": {2352, 1008}, "1:1-2k": {2048, 2048}, "16:9-2k": {2048, 1152}, + "9:16-2k": {1152, 2048}, "16:9-4k": {3840, 2160}, "9:16-4k": {2160, 3840}, + } + if dimensions, ok := preset[normalized]; ok { + return dimensions, true + } + ratio := workflowAspectRatio(normalized) + widthRatio, heightRatio, ok := workflowRatioParts(ratio) + if !ok { + return [2]int{}, false + } + tier := "1k" + if strings.HasSuffix(normalized, "-2k") { + tier = "2k" + } else if strings.HasSuffix(normalized, "-4k") { + tier = "4k" + } + if tier == "1k" { + if widthRatio >= heightRatio { + return [2]int{workflowRoundToStep(1024*float64(widthRatio)/float64(heightRatio), 16), 1024}, true + } + return [2]int{1024, workflowRoundToStep(1024*float64(heightRatio)/float64(widthRatio), 16)}, true + } + longEdge := 2048 + if tier == "4k" { + longEdge = 3840 + } + if widthRatio >= heightRatio { + return [2]int{longEdge, workflowRoundToStep(float64(longEdge)*float64(heightRatio)/float64(widthRatio), 16)}, true + } + return [2]int{workflowRoundToStep(float64(longEdge)*float64(widthRatio)/float64(heightRatio), 16), longEdge}, true +} + +func workflowVideoDimensions(size string, quality string) ([2]int, bool) { + ratio := workflowAspectRatio(size) + widthRatio, heightRatio, ok := workflowRatioParts(ratio) + shortEdge := workflowVideoResolutionPixels(quality) + if !ok || shortEdge <= 0 { + return [2]int{}, false + } + if widthRatio >= heightRatio { + return [2]int{workflowRoundToStep(float64(shortEdge)*float64(widthRatio)/float64(heightRatio), 2), shortEdge}, true + } + return [2]int{shortEdge, workflowRoundToStep(float64(shortEdge)*float64(heightRatio)/float64(widthRatio), 2)}, true +} + +func workflowRoundToStep(value float64, step int) int { + if step <= 1 { + return int(math.Round(value)) + } + return int(math.Round(value/float64(step))) * step +} + +func workflowVideoResolutionPixels(value string) int { + normalized := strings.ToLower(strings.TrimSpace(value)) + switch normalized { + case "low": + return 480 + case "auto", "default", "medium", "high": + return 720 + case "2k": + return 1440 + case "4k": + return 2160 + } + parsed, err := strconv.Atoi(strings.TrimSuffix(normalized, "p")) + if err != nil || parsed <= 0 { + return 0 + } + return parsed +} + +func workflowVideoResolutionValue(field WorkflowField, value string) interface{} { + raw := strings.TrimSpace(value) + if raw == "" { + return "" + } + if len(field.Options) > 0 { + requested := normalizeWorkflowResolutionToken(raw) + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowResolutionToken(candidate) == requested { + // nodeInfoList 的 fieldValue 是字符串;对象选项只取其 value/id 等标量。 + return candidate + } + } + // 工作流明确声明了可选项但画布值不在其中时,保留工作流默认值, + // 不再把 2K/4K/720 等普通模型别名硬塞给工作流。 + if fallback := workflowFieldConfiguredDefault(field); fallback != "" { + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowResolutionToken(candidate) == normalizeWorkflowResolutionToken(fallback) { + return candidate + } + } + return fallback + } + return raw + } + if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { + return numeric + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { + return fallback + } + return raw +} + +func workflowVideoDurationValue(field WorkflowField, value string) interface{} { + raw := strings.TrimSpace(value) + if raw == "" { + return "" + } + if len(field.Options) > 0 { + requested := normalizeWorkflowDurationToken(raw) + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowDurationToken(candidate) == requested { + return candidate + } + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" { + for _, option := range field.Options { + candidate := strings.TrimSpace(workflowOptionString(option)) + if candidate != "" && normalizeWorkflowDurationToken(candidate) == normalizeWorkflowDurationToken(fallback) { + return candidate + } + } + return fallback + } + return raw + } + if numeric := workflowNumericResolutionValue(field, raw); numeric != nil { + return numeric + } + if fallback := workflowFieldConfiguredDefault(field); fallback != "" && !workflowResolutionShapeCompatible(fallback, raw) { + return fallback + } + return raw +} + +func normalizeWorkflowDurationToken(value string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "s"), "秒"))) +} + +func workflowFieldConfiguredDefault(field WorkflowField) string { + value := field.FieldValue + if value == nil { + value = field.Value + } + if value == nil { + return "" + } + return strings.TrimSpace(workflowOptionString(value)) +} + +func normalizeWorkflowResolutionToken(value string) string { + return strings.ToLower(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P"))) +} + +func workflowNumericValue(value string) *float64 { + parsed, err := strconv.ParseFloat(strings.TrimSpace(strings.TrimSuffix(strings.TrimSuffix(value, "p"), "P")), 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) { + return nil + } + return &parsed +} + +func workflowResolutionShapeCompatible(defaultValue string, requested string) bool { + defaultNumeric := workflowNumericValue(defaultValue) + requestedNumeric := workflowNumericValue(requested) + if defaultNumeric != nil && requestedNumeric != nil { + return true + } + return strings.EqualFold(strings.TrimSpace(defaultValue), strings.TrimSpace(requested)) +} + +func workflowNumericResolutionValue(field WorkflowField, raw string) interface{} { + parsed := workflowNumericValue(raw) + if parsed == nil { + return nil + } + min, minOK := workflowNumericBound(field.Min) + max, maxOK := workflowNumericBound(field.Max) + step, stepOK := workflowNumericBound(field.Step) + if !minOK && !maxOK && !stepOK && !strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") { + return nil + } + value := *parsed + if minOK && value < min { + value = min + } + if maxOK && value > max { + value = max + } + if stepOK && step > 0 { + anchor := min + if !minOK { + anchor = 0 + } + value = anchor + math.Round((value-anchor)/step)*step + } + if strings.EqualFold(strings.TrimSpace(field.FieldType), "NUMBER") && math.Trunc(value) == value { + return int64(value) + } + return value +} + +func workflowNumericBound(value interface{}) (float64, bool) { + parsed, err := strconv.ParseFloat(strings.TrimSpace(fmt.Sprint(value)), 64) + return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0) +} + +func workflowOptionString(value interface{}) string { + if object, ok := value.(map[string]interface{}); ok { + for _, key := range []string{"value", "id", "key", "label", "name"} { + if candidate := strings.TrimSpace(fmt.Sprint(object[key])); candidate != "" && candidate != "" { + return candidate + } + } + } + return fmt.Sprint(value) +} + +func runningHubPromptFallback(workflow map[string]interface{}, prompt string) []map[string]any { + if strings.TrimSpace(prompt) == "" || len(workflow) == 0 { + return nil + } + bestNodeID := "" + bestFieldName := "" + bestScore := -1000 + nodeIDs := make([]string, 0, len(workflow)) + for nodeID := range workflow { + nodeIDs = append(nodeIDs, nodeID) + } + sort.Slice(nodeIDs, func(i, j int) bool { return managementNodeIDLess(nodeIDs[i], nodeIDs[j]) }) + for _, nodeID := range nodeIDs { + node, ok := workflow[nodeID].(map[string]interface{}) + if !ok { + continue + } + inputs, ok := node["inputs"].(map[string]interface{}) + if !ok { + continue + } + fieldNames := make([]string, 0, len(inputs)) + for fieldName := range inputs { + fieldNames = append(fieldNames, fieldName) + } + sort.Strings(fieldNames) + classType := strings.ToLower(strings.TrimSpace(fmt.Sprint(node["class_type"]))) + metaTitle := strings.ToLower(strings.TrimSpace(fmt.Sprint(workflowNodeMetaTitle(node)))) + for _, fieldName := range fieldNames { + if !isWorkflowPromptCandidate(fieldName, classType, metaTitle) { + continue + } + if isWorkflowLinkValue(inputs[fieldName]) { + continue + } + score := workflowPromptCandidateScore(fieldName, classType, metaTitle) + if bestNodeID == "" || score > bestScore { + bestNodeID = nodeID + bestFieldName = fieldName + bestScore = score + } + } + } + if bestNodeID != "" { + return []map[string]any{{"nodeId": bestNodeID, "fieldName": bestFieldName, "fieldValue": prompt}} + } + return nil +} + +func isWorkflowMediaSource(source string) bool { + normalized := strings.ReplaceAll(strings.ReplaceAll(strings.ToLower(strings.TrimSpace(source)), "_", ""), "-", "") + switch normalized { + case "referenceimage", "referenceimages", "image", "referencevideo", "referencevideos", "video", "referenceaudio", "referenceaudios", "audio", "mask": + return true + default: + return false + } +} + +func workflowPromptCandidateScore(fieldName string, classType string, metaTitle string) int { + descriptor := strings.ToLower(strings.TrimSpace(fieldName + " " + metaTitle)) + score := 0 + for _, marker := range []string{"negative", "neg prompt", "负面", "反向", "负向"} { + if strings.Contains(descriptor, marker) { + score -= 100 + break + } + } + for _, marker := range []string{"positive", "正面", "正向"} { + if strings.Contains(descriptor, marker) { + score += 20 + break + } + } + if strings.Contains(classType, "cliptextencode") { + score += 5 + } + if isWorkflowPromptFieldName(fieldName) { + score += 2 + } + return score +} + +func randomWorkflowInteger(rawMin interface{}, rawMax interface{}) (int64, error) { + const defaultMax int64 = 9007199254740991 + minValue, err := workflowIntegerBound(rawMin, 0) + if err != nil { + return 0, err + } + maxValue, err := workflowIntegerBound(rawMax, defaultMax) + if err != nil { + return 0, err + } + if maxValue < minValue { + return 0, errors.New("工作流随机值最大值不能小于最小值") + } + rangeSize := new(big.Int).Sub(big.NewInt(maxValue), big.NewInt(minValue)) + rangeSize.Add(rangeSize, big.NewInt(1)) + offset, err := cryptorand.Int(cryptorand.Reader, rangeSize) + if err != nil { + return 0, fmt.Errorf("生成工作流随机值失败:%w", err) + } + return new(big.Int).Add(offset, big.NewInt(minValue)).Int64(), nil +} + +func workflowIntegerBound(value interface{}, fallback int64) (int64, error) { + if value == nil || strings.TrimSpace(fmt.Sprint(value)) == "" { + return fallback, nil + } + parsed, err := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(value)), 10, 64) + if err != nil { + return 0, fmt.Errorf("工作流随机值范围不是有效整数:%v", value) + } + return parsed, nil +} + +func workflowFieldsBindPrompt(fields []WorkflowField) bool { + for _, field := range fields { + if field.Enabled != nil && !*field.Enabled || strings.TrimSpace(field.NodeID) == "" || strings.TrimSpace(field.FieldName) == "" { + continue + } + source := strings.ReplaceAll(strings.ReplaceAll(normalizeWorkflowFieldSource(field), "_", ""), "-", "") + switch source { + case "prompt", "text", "positiveprompt", "positive": + return true + } + } + return false +} + +func upsertRunningHubNodeInfo(items []map[string]any, overrides []map[string]any) []map[string]any { + for _, override := range overrides { + nodeID := strings.TrimSpace(stringValue(override["nodeId"])) + fieldName := strings.TrimSpace(stringValue(override["fieldName"])) + replaced := false + for index, item := range items { + if strings.TrimSpace(stringValue(item["nodeId"])) == nodeID && strings.TrimSpace(stringValue(item["fieldName"])) == fieldName { + items[index] = override + replaced = true + break + } + } + if !replaced { + items = append(items, override) + } + } + return items +} + +func isWorkflowLinkValue(value interface{}) bool { + items, ok := value.([]interface{}) + return ok && len(items) == 2 && fmt.Sprint(items[0]) != "" && isIntegerLike(items[1]) +} + +func isIntegerLike(value interface{}) bool { + switch item := value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return true + case float64: + return item == float64(int(item)) + case json.Number: + _, err := strconv.Atoi(string(item)) + return err == nil + default: + return false + } +} + +func isWorkflowPromptFieldName(value string) bool { + normalized := strings.ToLower(strings.NewReplacer("_", "", "-", "", " ", "").Replace(strings.TrimSpace(value))) + switch normalized { + case "text", "prompt", "positiveprompt", "positive", "caption", "description": + return true + default: + return false + } +} + +func isWorkflowPromptCandidate(fieldName string, classType string, metaTitle string) bool { + if isWorkflowPromptFieldName(fieldName) { + return true + } + normalizedField := strings.ToLower(strings.TrimSpace(fieldName)) + if normalizedField != "value" { + return false + } + return strings.Contains(classType, "text") || strings.Contains(classType, "string") || strings.Contains(classType, "prompt") || strings.Contains(metaTitle, "text") || strings.Contains(metaTitle, "prompt") || strings.Contains(metaTitle, "提示词") || strings.Contains(metaTitle, "文本") +} + +func workflowNodeMetaTitle(node map[string]interface{}) string { + meta, _ := node["_meta"].(map[string]interface{}) + return fmt.Sprint(meta["title"]) +} + +func workflowScalarString(value interface{}) string { + switch item := value.(type) { + case string: + return item + case nil: + return "" + default: + encoded, err := json.Marshal(item) + if err == nil { + return string(encoded) + } + return fmt.Sprint(item) + } +} diff --git a/backend/internal/handler/admin_api_log_routes.go b/backend/internal/handler/admin_api_log_routes.go new file mode 100644 index 000000000..449cbc108 --- /dev/null +++ b/backend/internal/handler/admin_api_log_routes.go @@ -0,0 +1,96 @@ +// 管理端上游调用日志接口:分页、详情、媒体预览、查询上游任务与 CSV 导出。 + +package handler + +import ( + "net/http" + + "strings" + "time" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAdminAPILogRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/api-logs", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 50) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit}) + if err != nil { + failService(c, err) + return + } + ok(c, logs) + }) + r.GET("/admin/api-logs/:id/media", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + delivery, err := svc.PrepareAdminAPICallLogMediaDelivery(user, c.Param("id"), resourceAccessOptions(c), c.GetHeader("Range")) + if err != nil { + failService(c, err) + return + } + var disposition string + if c.Query("download") == "1" { + disposition = "attachment" + } + serveResourceDelivery(c, delivery, "private, no-cache", disposition) + }) + r.GET("/admin/api-logs/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + log, err := svc.AdminAPICallLog(user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"log": log}) + }) + r.POST("/admin/api-logs/:id/query-task", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + result, err := svc.AdminQueryFailedVideoTask(c.Request.Context(), user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/api-logs-export.csv", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + selectedIDs := []string(nil) + if value := strings.TrimSpace(c.Query("ids")); value != "" { + selectedIDs = strings.Split(value, ",") + } + data, err := svc.AdminAPICallLogsCSV(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), IDs: selectedIDs}) + if err != nil { + failService(c, err) + return + } + c.Header("Content-Disposition", "attachment; filename=api-calls-"+time.Now().UTC().Format("20060102-150405")+".csv") + c.Data(http.StatusOK, "text/csv; charset=utf-8", data) + }) +} diff --git a/backend/internal/handler/admin_channel_routes.go b/backend/internal/handler/admin_channel_routes.go new file mode 100644 index 000000000..f2e889989 --- /dev/null +++ b/backend/internal/handler/admin_channel_routes.go @@ -0,0 +1,156 @@ +// 管理端系统渠道与提示词模板接口。 + +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAdminChannelRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/channels", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + channels, err := svc.AdminSystemChannelPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit}) + if err != nil { + failService(c, err) + return + } + ok(c, channels) + }) + r.POST("/admin/channels", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.ChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + channel, err := svc.CreateSystemChannel(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"channel": channel}) + }) + r.POST("/admin/channels/:id/duplicate", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + channel, err := svc.DuplicateSystemChannel(user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"channel": channel}) + }) + r.PATCH("/admin/channels/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.ChannelRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + channel, err := svc.UpdateSystemChannel(user, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"channel": channel}) + }) + r.DELETE("/admin/channels/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteSystemChannel(user, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) + r.GET("/admin/prompt-templates", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + templates, definitions, err := svc.AdminPromptTemplates(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"templates": templates, "definitions": definitions}) + }) + r.POST("/admin/prompt-templates", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.PromptTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + template, err := svc.CreatePromptTemplate(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"template": template}) + }) + r.PATCH("/admin/prompt-templates/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.PromptTemplateRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + template, err := svc.UpdatePromptTemplate(user, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"template": template}) + }) + r.DELETE("/admin/prompt-templates/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeletePromptTemplate(user, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) +} diff --git a/backend/internal/handler/admin_setting_routes.go b/backend/internal/handler/admin_setting_routes.go new file mode 100644 index 000000000..0d0bf96a3 --- /dev/null +++ b/backend/internal/handler/admin_setting_routes.go @@ -0,0 +1,190 @@ +// 管理端系统设置接口:对象存储、方舟私有素材、绘图引擎与资源/请求运行策略。 + +package handler + +import ( + "net/http" + + "time" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAdminSettingRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/settings/oss", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminOSSSetting(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.PATCH("/admin/settings/oss", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.OSSSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateOSSSetting(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.POST("/admin/settings/oss/test", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if !enforceRateLimit(c, "admin-storage-test:"+user.ID, 6, time.Minute) { + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.OSSSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.TestAdminOSSSetting(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/settings/ark-private-assets", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminArkPrivateAssetSetting(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.PATCH("/admin/settings/ark-private-assets", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.ArkPrivateAssetSettingRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateArkPrivateAssetSetting(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.GET("/admin/settings/drawing-engine", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminDrawingEngineSetting(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.PATCH("/admin/settings/drawing-engine", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.DrawingEngineSetting + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateDrawingEngineSetting(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.GET("/admin/settings/runtime-policy", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminRuntimePolicySetting(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.GET("/admin/settings/runtime-policy/self-use", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.AdminSelfUseRuntimePolicy(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.PUT("/admin/settings/runtime-policy", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.RuntimePolicySetting + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + setting, err := svc.UpdateRuntimePolicySetting(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) + r.DELETE("/admin/settings/runtime-policy", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + setting, err := svc.ResetRuntimePolicySetting(user) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"setting": setting}) + }) +} diff --git a/backend/internal/handler/admin_user_routes.go b/backend/internal/handler/admin_user_routes.go new file mode 100644 index 000000000..049156568 --- /dev/null +++ b/backend/internal/handler/admin_user_routes.go @@ -0,0 +1,182 @@ +// 管理端用户管理接口:列表、创建、详情、积分流水、任务、审计事件、更新与删除。 +// +// 所有接口在 service 层通过 RequireAdmin 校验身份;这里只做参数解析与响应封装。 + +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerAdminUserRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/admin/users", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + users, err := svc.AdminUsers(user, service.AdminListQuery{Keyword: c.Query("keyword"), Type: c.Query("role"), Status: c.Query("status"), Page: page, Limit: limit}) + if err != nil { + failService(c, err) + return + } + ok(c, users) + }) + r.POST("/admin/users", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.CreateAdminUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + created, err := svc.CreateAdminUser(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": created}) + }) + r.GET("/admin/references", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + data, err := svc.AdminReferences(user) + if err != nil { + failService(c, err) + return + } + ok(c, data) + }) + r.POST("/admin/users/bulk-disable", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.BulkDisableUsersRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.BulkDisableUsers(user, req) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/users/:id/detail", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + result, err := svc.AdminUserDetail(user, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/users/:id/ledger", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.AdminUserLedger(user, c.Param("id"), c.Query("type"), page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/users/:id/tasks", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.AdminUserTasks(user, c.Param("id"), page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.GET("/admin/users/:id/audit-events", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, limit, err := parsePaginationQuery(c, 20) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.AdminUserAuditEvents(user, c.Param("id"), page, limit) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.PATCH("/admin/users/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + var req service.UpdateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + updated, err := svc.UpdateUser(user, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"user": updated}) + }) + r.DELETE("/admin/users/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteUser(user, c.Param("id")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"ok": true}) + }) +} diff --git a/backend/internal/handler/asset_delete_test.go b/backend/internal/handler/asset_delete_test.go index de317887c..f4889c638 100644 --- a/backend/internal/handler/asset_delete_test.go +++ b/backend/internal/handler/asset_delete_test.go @@ -107,7 +107,7 @@ func TestAssetBatchDeleteHTTP(t *testing.T) { } assertAssets(1) - // The DELETE endpoint protects referenced assets just like batch deletion. + // DELETE /assets/:id 与批量删除是同一个「彻底删除」契约:引用不拦截,任务记录保留。 resource := model.Resource{ID: "single-resource", UserID: "batch-user", Provider: "unsupported-test-provider", ObjectKey: "single.png"} payload := `{"url":"/api/resources/single-resource/file"}` for _, record := range []any{ @@ -119,8 +119,12 @@ func TestAssetBatchDeleteHTTP(t *testing.T) { t.Fatal(err) } } - if w := call(http.MethodDelete, "/api/assets/single", "", true); w.Code != http.StatusBadRequest { + if w := call(http.MethodDelete, "/api/assets/single", "", true); w.Code != http.StatusOK { t.Fatalf("single referenced delete: %d %s", w.Code, w.Body.String()) } - assertAssets(2) + assertAssets(1) + var tasks int64 + if err := db.Model(&model.Task{}).Where("id = ?", "running-task").Count(&tasks).Error; err != nil || tasks != 1 { + t.Fatalf("task history must be kept: count=%d err=%v", tasks, err) + } } diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 5dec5df49..967ad33e1 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -221,567 +221,10 @@ func linuxDOCallbackHandler(svc *service.Service) gin.HandlerFunc { } func RegisterAdminRoutes(r *gin.RouterGroup, svc *service.Service) { - r.GET("/admin/users", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, limit, err := parsePaginationQuery(c, 20) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - users, err := svc.AdminUsers(user, service.AdminListQuery{Keyword: c.Query("keyword"), Type: c.Query("role"), Status: c.Query("status"), Page: page, Limit: limit}) - if err != nil { - failService(c, err) - return - } - ok(c, users) - }) - r.POST("/admin/users", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.CreateAdminUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - created, err := svc.CreateAdminUser(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"user": created}) - }) - r.GET("/admin/references", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - data, err := svc.AdminReferences(user) - if err != nil { - failService(c, err) - return - } - ok(c, data) - }) - r.POST("/admin/users/bulk-disable", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.BulkDisableUsersRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.BulkDisableUsers(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/admin/users/:id/detail", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - result, err := svc.AdminUserDetail(user, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/admin/users/:id/ledger", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, limit, err := parsePaginationQuery(c, 20) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.AdminUserLedger(user, c.Param("id"), c.Query("type"), page, limit) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/admin/users/:id/tasks", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, limit, err := parsePaginationQuery(c, 20) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.AdminUserTasks(user, c.Param("id"), page, limit) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/admin/users/:id/audit-events", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, limit, err := parsePaginationQuery(c, 20) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.AdminUserAuditEvents(user, c.Param("id"), page, limit) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.PATCH("/admin/users/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - var req service.UpdateUserRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - updated, err := svc.UpdateUser(user, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"user": updated}) - }) - r.DELETE("/admin/users/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeleteUser(user, c.Param("id")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"ok": true}) - }) - r.GET("/admin/channels", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, limit, err := parsePaginationQuery(c, 20) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - channels, err := svc.AdminSystemChannelPage(user, service.AdminListQuery{Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit}) - if err != nil { - failService(c, err) - return - } - ok(c, channels) - }) - r.POST("/admin/channels", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - var req service.ChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - channel, err := svc.CreateSystemChannel(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"channel": channel}) - }) - r.POST("/admin/channels/:id/duplicate", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - channel, err := svc.DuplicateSystemChannel(user, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"channel": channel}) - }) - r.PATCH("/admin/channels/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - var req service.ChannelRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - channel, err := svc.UpdateSystemChannel(user, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"channel": channel}) - }) - r.DELETE("/admin/channels/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeleteSystemChannel(user, c.Param("id")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"ok": true}) - }) - r.GET("/admin/prompt-templates", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - templates, definitions, err := svc.AdminPromptTemplates(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"templates": templates, "definitions": definitions}) - }) - r.POST("/admin/prompt-templates", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.PromptTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - template, err := svc.CreatePromptTemplate(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"template": template}) - }) - r.PATCH("/admin/prompt-templates/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.PromptTemplateRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - template, err := svc.UpdatePromptTemplate(user, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"template": template}) - }) - r.DELETE("/admin/prompt-templates/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeletePromptTemplate(user, c.Param("id")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"ok": true}) - }) - r.GET("/admin/settings/oss", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - setting, err := svc.AdminOSSSetting(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.PATCH("/admin/settings/oss", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.OSSSettingRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - setting, err := svc.UpdateOSSSetting(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.POST("/admin/settings/oss/test", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if !enforceRateLimit(c, "admin-storage-test:"+user.ID, 6, time.Minute) { - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.OSSSettingRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.TestAdminOSSSetting(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/admin/settings/ark-private-assets", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - setting, err := svc.AdminArkPrivateAssetSetting(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.PATCH("/admin/settings/ark-private-assets", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - var req service.ArkPrivateAssetSettingRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - setting, err := svc.UpdateArkPrivateAssetSetting(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.GET("/admin/settings/drawing-engine", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - setting, err := svc.AdminDrawingEngineSetting(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.PATCH("/admin/settings/drawing-engine", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - var req service.DrawingEngineSetting - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - setting, err := svc.UpdateDrawingEngineSetting(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.GET("/admin/settings/runtime-policy", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - setting, err := svc.AdminRuntimePolicySetting(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.GET("/admin/settings/runtime-policy/self-use", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - setting, err := svc.AdminSelfUseRuntimePolicy(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.PUT("/admin/settings/runtime-policy", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.RuntimePolicySetting - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - setting, err := svc.UpdateRuntimePolicySetting(user, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.DELETE("/admin/settings/runtime-policy", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - setting, err := svc.ResetRuntimePolicySetting(user) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"setting": setting}) - }) - r.GET("/admin/api-logs", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, limit, err := parsePaginationQuery(c, 50) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - logs, err := svc.AdminAPICallLogs(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), Page: page, Limit: limit}) - if err != nil { - failService(c, err) - return - } - ok(c, logs) - }) - r.GET("/admin/api-logs/:id/media", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - delivery, err := svc.PrepareAdminAPICallLogMediaDelivery(user, c.Param("id"), resourceAccessOptions(c), c.GetHeader("Range")) - if err != nil { - failService(c, err) - return - } - var disposition string - if c.Query("download") == "1" { - disposition = "attachment" - } - serveResourceDelivery(c, delivery, "private, no-cache", disposition) - }) - r.GET("/admin/api-logs/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - log, err := svc.AdminAPICallLog(user, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"log": log}) - }) - r.POST("/admin/api-logs/:id/query-task", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - result, err := svc.AdminQueryFailedVideoTask(c.Request.Context(), user, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/admin/api-logs-export.csv", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - selectedIDs := []string(nil) - if value := strings.TrimSpace(c.Query("ids")); value != "" { - selectedIDs = strings.Split(value, ",") - } - data, err := svc.AdminAPICallLogsCSV(user, service.APICallLogQuery{AnalyticsQuery: analyticsQuery(c), RecordType: c.Query("recordType"), Keyword: c.Query("keyword"), Status: c.Query("status"), IDs: selectedIDs}) - if err != nil { - failService(c, err) - return - } - c.Header("Content-Disposition", "attachment; filename=api-calls-"+time.Now().UTC().Format("20060102-150405")+".csv") - c.Data(http.StatusOK, "text/csv; charset=utf-8", data) - }) + registerAdminUserRoutes(r, svc) + registerAdminChannelRoutes(r, svc) + registerAdminSettingRoutes(r, svc) + registerAdminAPILogRoutes(r, svc) } func RegisterSystemProxyRoutes(r *gin.RouterGroup, svc *service.Service) { diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index d795f323a..72bab6c30 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -2,7 +2,7 @@ package handler import ( "fmt" - "net/http" + "strconv" "infinite-canvas/backend/internal/service" @@ -25,886 +25,10 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"profiles": profiles}) }) - r.GET("/projects", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - pageParam, hasPage := c.GetQuery("page") - pageSizeParam, hasPageSize := c.GetQuery("pageSize") - if !hasPage && !hasPageSize { - projects, err := svc.ListProjects(user.ID) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"projects": projects}) - return - } - page, err := parsePositiveQueryInt(pageParam, 1) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - pageSize, err := parsePositiveQueryInt(pageSizeParam, 50) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - projects, err := svc.ListProjectsPage(user.ID, page, pageSize) - if err != nil { - failService(c, err) - return - } - ok(c, projects) - }) - r.POST("/projects", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.CreateProjectRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - project, err := svc.CreateProject(user.ID, req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"project": project}) - }) - r.GET("/projects/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - detail, err := svc.ProjectDetail(user.ID, c.Param("id")) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, detail) - }) - r.GET("/projects/:id/core", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - core, err := svc.ProjectCore(user.ID, c.Param("id")) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, core) - }) - r.GET("/projects/:id/overview", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - overview, err := svc.ProjectOverview(user.ID, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, overview) - }) - r.PATCH("/projects/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.UpdateProjectRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - project, err := svc.UpdateProject(user.ID, c.Param("id"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"project": project}) - }) - r.DELETE("/projects/:id", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeleteProject(user.ID, c.Param("id")); err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"id": c.Param("id")}) - }) - r.POST("/projects/:id/units", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) - var req service.CreateProjectUnitRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - unit, err := svc.CreateProjectUnit(user.ID, c.Param("id"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"unit": unit}) - }) - r.GET("/projects/:id/units", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - units, err := svc.ProjectUnitSummaries(user.ID, c.Param("id")) - if err != nil { - failService(c, err) - return - } - ok(c, units) - }) - r.GET("/projects/:id/units/:unitId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - unit, err := svc.GetProjectUnit(user.ID, c.Param("id"), c.Param("unitId")) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"unit": unit}) - }) - r.GET("/projects/:id/units/:unitId/workspace", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - workspace, err := svc.ProjectUnitWorkspace(user.ID, c.Param("id"), c.Param("unitId")) - if err != nil { - failService(c, err) - return - } - ok(c, workspace) - }) - r.POST("/projects/:id/units/import", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - // 长篇小说可能包含两千章以上,限制请求体防止滥用的同时为整本原子导入留足空间。 - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<20) - var req service.ImportProjectUnitsRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - units, err := svc.ImportProjectUnits(user.ID, c.Param("id"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - // 导入响应只需返回新章节标识与摘要,正文不再原样回传一次。 - for index := range units { - units[index].SourceText = "" - } - ok(c, gin.H{"units": units}) - }) - r.PATCH("/projects/:id/units/reorder", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.ReorderProjectUnitsRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - if err := svc.ReorderProjectUnits(user.ID, c.Param("id"), req); err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"unitIds": req.UnitIDs}) - }) - r.PATCH("/projects/:id/units/:unitId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) - var req service.UpdateProjectUnitRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - unit, err := svc.UpdateProjectUnit(user.ID, c.Param("id"), c.Param("unitId"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"unit": unit}) - }) - r.DELETE("/projects/:id/units/:unitId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeleteProjectUnit(user.ID, c.Param("id"), c.Param("unitId")); err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"id": c.Param("unitId")}) - }) - r.POST("/projects/:id/canvas-links", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.LinkCanvasUnitRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - link, err := svc.LinkCanvasUnit(user.ID, c.Param("id"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"link": link}) - }) - r.DELETE("/projects/:id/canvas-links/:canvasId/units/:unitId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.UnlinkCanvasUnit(user.ID, c.Param("id"), c.Param("canvasId"), c.Param("unitId")); err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"canvasId": c.Param("canvasId"), "unitId": c.Param("unitId")}) - }) - r.DELETE("/projects/:id/canvases/:canvasId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.UnlinkCanvasProject(user.ID, c.Param("id"), c.Param("canvasId")); err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"canvasId": c.Param("canvasId")}) - }) - r.GET("/projects/:id/canvases", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, err := parsePositiveQueryInt(c.Query("page"), 1) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 40) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.ProjectCanvasesPage(user.ID, c.Param("id"), page, pageSize) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.GET("/projects/:id/assets", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - pageParam, hasPage := c.GetQuery("page") - pageSizeParam, hasPageSize := c.GetQuery("pageSize") - if hasPage || hasPageSize { - page, pageErr := parsePositiveQueryInt(pageParam, 1) - if pageErr != nil { - fail(c, http.StatusBadRequest, pageErr) - return - } - pageSize, pageSizeErr := parsePositiveQueryInt(pageSizeParam, 40) - if pageSizeErr != nil { - fail(c, http.StatusBadRequest, pageSizeErr) - return - } - var folderID *string - if value, present := c.GetQuery("folderId"); present { - folderID = &value - } - assets, pageErr := svc.ProjectAssetsPage(user.ID, c.Param("id"), page, pageSize, c.Query("category"), c.Query("mediaType"), c.Query("status"), folderID, c.Query("q")) - if pageErr != nil { - failService(c, pageErr) - return - } - ok(c, assets) - return - } - assets, err := svc.FilterProjectAssets(user.ID, c.Param("id"), service.ProjectAssetFilter{Category: c.Query("category"), MediaType: c.Query("mediaType"), Status: c.Query("status"), Usage: c.Query("usage")}) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"assets": assets}) - }) - r.GET("/projects/:id/asset-folders", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - folders, err := svc.ProjectAssetFolders(user.ID, c.Param("id")) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"folders": folders}) - }) - r.POST("/projects/:id/asset-folders", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) - var req service.CreateProjectAssetFolderRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - folder, err := svc.CreateProjectAssetFolder(user.ID, c.Param("id"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"folder": folder}) - }) - r.PATCH("/projects/:id/asset-folders/:folderId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) - var req service.UpdateProjectAssetFolderRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - folder, err := svc.UpdateProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId"), req) - if err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"folder": folder}) - }) - r.DELETE("/projects/:id/asset-folders/:folderId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeleteProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId")); err != nil { - if service.IsProjectNotFound(err) { - fail(c, http.StatusNotFound, err) - return - } - failService(c, err) - return - } - ok(c, gin.H{"id": c.Param("folderId")}) - }) - r.POST("/projects/:id/characters", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.CreateProjectCharacterRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.CreateProjectCharacter(user.ID, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.GET("/projects/:id/characters/:assetId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - character, err := svc.ProjectCharacter(user.ID, c.Param("id"), c.Param("assetId")) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.PATCH("/projects/:id/characters/:assetId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.UpdateProjectCharacterRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.UpdateProjectCharacter(user.ID, c.Param("id"), c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.PUT("/projects/:id/characters/:assetId/representations", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10) - var req service.ReplaceCharacterRepresentationsRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.ReplaceProjectCharacterRepresentations(user.ID, c.Param("id"), c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.PUT("/projects/:id/characters/:assetId/voice", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.BindCharacterVoiceRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.BindProjectCharacterVoice(user.ID, c.Param("id"), c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.DELETE("/projects/:id/characters/:assetId/voice", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - character, err := svc.UnbindProjectCharacterVoice(user.ID, c.Param("id"), c.Param("assetId")) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.POST("/projects/:id/assets", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.LinkProjectAssetRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - asset, err := svc.LinkProjectAsset(user.ID, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"asset": asset}) - }) - r.DELETE("/projects/:id/assets/:assetId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.UnlinkProjectAsset(user.ID, c.Param("id"), c.Param("assetId")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"id": c.Param("assetId")}) - }) - r.PATCH("/projects/:id/assets/:assetId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.UpdateProjectAssetRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - asset, err := svc.UpdateProjectAsset(user.ID, c.Param("id"), c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"asset": asset}) - }) - r.POST("/projects/:id/assets/:assetId/versions", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.CreateAssetVersionRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - version, err := svc.CreateProjectAssetVersion(user.ID, c.Param("id"), c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"version": version}) - }) - r.POST("/projects/:id/workflows", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req struct { - UnitID string `json:"unitId"` - } - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - workflow, err := svc.CreateUnitWorkflow(user.ID, c.Param("id"), req.UnitID) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"workflow": workflow}) - }) - r.PATCH("/projects/:id/workflow-steps/:stepId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.UpdateWorkflowStepRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - step, err := svc.UpdateWorkflowStep(user.ID, c.Param("id"), c.Param("stepId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"step": step}) - }) - r.POST("/projects/:id/workflow-steps/:stepId/task-output", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.RegisterTaskOutputRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - step, err := svc.RegisterTaskOutput(user.ID, c.Param("id"), c.Param("stepId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"step": step}) - }) - r.POST("/projects/:id/shots", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.CreateProjectShotRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - shot, err := svc.CreateProjectShot(user.ID, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"shot": shot}) - }) - r.PUT("/projects/:id/units/:unitId/shots", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) - var req service.ReplaceProjectUnitShotsRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - shots, err := svc.ReplaceProjectUnitShots(user.ID, c.Param("id"), c.Param("unitId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"shots": shots}) - }) - r.POST("/projects/:id/shots/:shotId/revisions", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.ShotRevisionInput - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - shot, revision, err := svc.CreateShotRevision(user.ID, c.Param("id"), c.Param("shotId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"shot": shot, "revision": revision}) - }) - r.DELETE("/projects/:id/shots/:shotId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.DeleteProjectShot(user.ID, c.Param("id"), c.Param("shotId")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"deleted": true}) - }) - r.POST("/projects/:id/shots/:shotId/assets", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.LinkShotAssetRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - reference, err := svc.LinkShotAsset(user.ID, c.Param("id"), c.Param("shotId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"reference": reference}) - }) - r.DELETE("/projects/:id/shots/:shotId/assets/:referenceId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - if err := svc.UnlinkShotAsset(user.ID, c.Param("id"), c.Param("shotId"), c.Param("referenceId")); err != nil { - failService(c, err) - return - } - ok(c, gin.H{"unlinked": true}) - }) - r.POST("/projects/:id/asset-candidates", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 512<<10) - var req service.CreateAssetCandidatesRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - candidates, err := svc.CreateProjectAssetCandidates(user.ID, c.Param("id"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"candidates": candidates}) - }) - r.GET("/projects/:id/asset-candidates", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, err := parsePositiveQueryInt(c.Query("page"), 1) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 100) - if err != nil { - fail(c, http.StatusBadRequest, err) - return - } - result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unitId"), c.Query("status"), c.Query("category"), c.Query("q")) - if err != nil { - failService(c, err) - return - } - ok(c, result) - }) - r.POST("/projects/:id/asset-candidates/:candidateId/confirm", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.ConfirmProjectAssetCandidateRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - asset, err := svc.ConfirmProjectAssetCandidate(user.ID, c.Param("id"), c.Param("candidateId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, gin.H{"asset": asset}) - }) + registerProjectCoreRoutes(r, svc) + registerProjectUnitRoutes(r, svc) + registerProjectAssetRoutes(r, svc) + registerProjectWorkflowRoutes(r, svc) } // parsePositiveQueryInt 解析列表查询里的正整数。分页和筛选查询名统一用 camelCase:page、pageSize、projectId、folderId、mediaType、unitId。 diff --git a/backend/internal/handler/project_asset_routes.go b/backend/internal/handler/project_asset_routes.go new file mode 100644 index 000000000..c15c11938 --- /dev/null +++ b/backend/internal/handler/project_asset_routes.go @@ -0,0 +1,302 @@ +// 项目素材库、素材文件夹与角色(含定妆表现、音色)接口。 + +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerProjectAssetRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/projects/:id/assets", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + pageParam, hasPage := c.GetQuery("page") + pageSizeParam, hasPageSize := c.GetQuery("pageSize") + if hasPage || hasPageSize { + page, pageErr := parsePositiveQueryInt(pageParam, 1) + if pageErr != nil { + fail(c, http.StatusBadRequest, pageErr) + return + } + pageSize, pageSizeErr := parsePositiveQueryInt(pageSizeParam, 40) + if pageSizeErr != nil { + fail(c, http.StatusBadRequest, pageSizeErr) + return + } + var folderID *string + if value, present := c.GetQuery("folderId"); present { + folderID = &value + } + assets, pageErr := svc.ProjectAssetsPage(user.ID, c.Param("id"), page, pageSize, c.Query("category"), c.Query("mediaType"), c.Query("status"), folderID, c.Query("q")) + if pageErr != nil { + failService(c, pageErr) + return + } + ok(c, assets) + return + } + assets, err := svc.FilterProjectAssets(user.ID, c.Param("id"), service.ProjectAssetFilter{Category: c.Query("category"), MediaType: c.Query("mediaType"), Status: c.Query("status"), Usage: c.Query("usage")}) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"assets": assets}) + }) + r.GET("/projects/:id/asset-folders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + folders, err := svc.ProjectAssetFolders(user.ID, c.Param("id")) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"folders": folders}) + }) + r.POST("/projects/:id/asset-folders", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + var req service.CreateProjectAssetFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + folder, err := svc.CreateProjectAssetFolder(user.ID, c.Param("id"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"folder": folder}) + }) + r.PATCH("/projects/:id/asset-folders/:folderId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<10) + var req service.UpdateProjectAssetFolderRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + folder, err := svc.UpdateProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"folder": folder}) + }) + r.DELETE("/projects/:id/asset-folders/:folderId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteProjectAssetFolder(user.ID, c.Param("id"), c.Param("folderId")); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("folderId")}) + }) + r.POST("/projects/:id/characters", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.CreateProjectCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.CreateProjectCharacter(user.ID, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.GET("/projects/:id/characters/:assetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + character, err := svc.ProjectCharacter(user.ID, c.Param("id"), c.Param("assetId")) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.PATCH("/projects/:id/characters/:assetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.UpdateProjectCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.UpdateProjectCharacter(user.ID, c.Param("id"), c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.PUT("/projects/:id/characters/:assetId/representations", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10) + var req service.ReplaceCharacterRepresentationsRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.ReplaceProjectCharacterRepresentations(user.ID, c.Param("id"), c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.PUT("/projects/:id/characters/:assetId/voice", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.BindCharacterVoiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.BindProjectCharacterVoice(user.ID, c.Param("id"), c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.DELETE("/projects/:id/characters/:assetId/voice", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + character, err := svc.UnbindProjectCharacterVoice(user.ID, c.Param("id"), c.Param("assetId")) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.POST("/projects/:id/assets", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.LinkProjectAssetRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + asset, err := svc.LinkProjectAsset(user.ID, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"asset": asset}) + }) + r.DELETE("/projects/:id/assets/:assetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.UnlinkProjectAsset(user.ID, c.Param("id"), c.Param("assetId")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("assetId")}) + }) + r.PATCH("/projects/:id/assets/:assetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.UpdateProjectAssetRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + asset, err := svc.UpdateProjectAsset(user.ID, c.Param("id"), c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"asset": asset}) + }) + r.POST("/projects/:id/assets/:assetId/versions", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.CreateAssetVersionRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + version, err := svc.CreateProjectAssetVersion(user.ID, c.Param("id"), c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"version": version}) + }) +} diff --git a/backend/internal/handler/project_core_routes.go b/backend/internal/handler/project_core_routes.go new file mode 100644 index 000000000..49b43c1c2 --- /dev/null +++ b/backend/internal/handler/project_core_routes.go @@ -0,0 +1,153 @@ +// 短剧项目接口:项目列表、创建、详情、概览、更新与删除。 + +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerProjectCoreRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/projects", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + pageParam, hasPage := c.GetQuery("page") + pageSizeParam, hasPageSize := c.GetQuery("pageSize") + if !hasPage && !hasPageSize { + projects, err := svc.ListProjects(user.ID) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"projects": projects}) + return + } + page, err := parsePositiveQueryInt(pageParam, 1) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + pageSize, err := parsePositiveQueryInt(pageSizeParam, 50) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + projects, err := svc.ListProjectsPage(user.ID, page, pageSize) + if err != nil { + failService(c, err) + return + } + ok(c, projects) + }) + r.POST("/projects", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.CreateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + project, err := svc.CreateProject(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"project": project}) + }) + r.GET("/projects/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + detail, err := svc.ProjectDetail(user.ID, c.Param("id")) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, detail) + }) + r.GET("/projects/:id/core", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + core, err := svc.ProjectCore(user.ID, c.Param("id")) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, core) + }) + r.GET("/projects/:id/overview", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + overview, err := svc.ProjectOverview(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, overview) + }) + r.PATCH("/projects/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.UpdateProjectRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + project, err := svc.UpdateProject(user.ID, c.Param("id"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"project": project}) + }) + r.DELETE("/projects/:id", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteProject(user.ID, c.Param("id")); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("id")}) + }) +} diff --git a/backend/internal/handler/project_unit_routes.go b/backend/internal/handler/project_unit_routes.go new file mode 100644 index 000000000..7f491e1e9 --- /dev/null +++ b/backend/internal/handler/project_unit_routes.go @@ -0,0 +1,247 @@ +// 项目分集(unit)与画布关联接口。 + +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerProjectUnitRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/projects/:id/units", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) + var req service.CreateProjectUnitRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + unit, err := svc.CreateProjectUnit(user.ID, c.Param("id"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"unit": unit}) + }) + r.GET("/projects/:id/units", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + units, err := svc.ProjectUnitSummaries(user.ID, c.Param("id")) + if err != nil { + failService(c, err) + return + } + ok(c, units) + }) + r.GET("/projects/:id/units/:unitId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + unit, err := svc.GetProjectUnit(user.ID, c.Param("id"), c.Param("unitId")) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"unit": unit}) + }) + r.GET("/projects/:id/units/:unitId/workspace", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + workspace, err := svc.ProjectUnitWorkspace(user.ID, c.Param("id"), c.Param("unitId")) + if err != nil { + failService(c, err) + return + } + ok(c, workspace) + }) + r.POST("/projects/:id/units/import", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + // 长篇小说可能包含两千章以上,限制请求体防止滥用的同时为整本原子导入留足空间。 + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32<<20) + var req service.ImportProjectUnitsRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + units, err := svc.ImportProjectUnits(user.ID, c.Param("id"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + // 导入响应只需返回新章节标识与摘要,正文不再原样回传一次。 + for index := range units { + units[index].SourceText = "" + } + ok(c, gin.H{"units": units}) + }) + r.PATCH("/projects/:id/units/reorder", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.ReorderProjectUnitsRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + if err := svc.ReorderProjectUnits(user.ID, c.Param("id"), req); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"unitIds": req.UnitIDs}) + }) + r.PATCH("/projects/:id/units/:unitId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) + var req service.UpdateProjectUnitRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + unit, err := svc.UpdateProjectUnit(user.ID, c.Param("id"), c.Param("unitId"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"unit": unit}) + }) + r.DELETE("/projects/:id/units/:unitId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteProjectUnit(user.ID, c.Param("id"), c.Param("unitId")); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"id": c.Param("unitId")}) + }) + r.POST("/projects/:id/canvas-links", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.LinkCanvasUnitRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + link, err := svc.LinkCanvasUnit(user.ID, c.Param("id"), req) + if err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"link": link}) + }) + r.DELETE("/projects/:id/canvas-links/:canvasId/units/:unitId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.UnlinkCanvasUnit(user.ID, c.Param("id"), c.Param("canvasId"), c.Param("unitId")); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"canvasId": c.Param("canvasId"), "unitId": c.Param("unitId")}) + }) + r.DELETE("/projects/:id/canvases/:canvasId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.UnlinkCanvasProject(user.ID, c.Param("id"), c.Param("canvasId")); err != nil { + if service.IsProjectNotFound(err) { + fail(c, http.StatusNotFound, err) + return + } + failService(c, err) + return + } + ok(c, gin.H{"canvasId": c.Param("canvasId")}) + }) + r.GET("/projects/:id/canvases", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, err := parsePositiveQueryInt(c.Query("page"), 1) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 40) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ProjectCanvasesPage(user.ID, c.Param("id"), page, pageSize) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) +} diff --git a/backend/internal/handler/project_workflow_routes.go b/backend/internal/handler/project_workflow_routes.go new file mode 100644 index 000000000..f1e704e26 --- /dev/null +++ b/backend/internal/handler/project_workflow_routes.go @@ -0,0 +1,234 @@ +// 项目工作流、镜头、镜头素材引用与素材候选接口。 + +package handler + +import ( + "net/http" + + "infinite-canvas/backend/internal/service" + + "github.com/gin-gonic/gin" +) + +func registerProjectWorkflowRoutes(r *gin.RouterGroup, svc *service.Service) { + r.POST("/projects/:id/workflows", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req struct { + UnitID string `json:"unitId"` + } + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + workflow, err := svc.CreateUnitWorkflow(user.ID, c.Param("id"), req.UnitID) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"workflow": workflow}) + }) + r.PATCH("/projects/:id/workflow-steps/:stepId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.UpdateWorkflowStepRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + step, err := svc.UpdateWorkflowStep(user.ID, c.Param("id"), c.Param("stepId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"step": step}) + }) + r.POST("/projects/:id/workflow-steps/:stepId/task-output", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.RegisterTaskOutputRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + step, err := svc.RegisterTaskOutput(user.ID, c.Param("id"), c.Param("stepId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"step": step}) + }) + r.POST("/projects/:id/shots", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.CreateProjectShotRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + shot, err := svc.CreateProjectShot(user.ID, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"shot": shot}) + }) + r.PUT("/projects/:id/units/:unitId/shots", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 2<<20) + var req service.ReplaceProjectUnitShotsRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + shots, err := svc.ReplaceProjectUnitShots(user.ID, c.Param("id"), c.Param("unitId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"shots": shots}) + }) + r.POST("/projects/:id/shots/:shotId/revisions", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.ShotRevisionInput + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + shot, revision, err := svc.CreateShotRevision(user.ID, c.Param("id"), c.Param("shotId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"shot": shot, "revision": revision}) + }) + r.DELETE("/projects/:id/shots/:shotId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.DeleteProjectShot(user.ID, c.Param("id"), c.Param("shotId")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"deleted": true}) + }) + r.POST("/projects/:id/shots/:shotId/assets", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.LinkShotAssetRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + reference, err := svc.LinkShotAsset(user.ID, c.Param("id"), c.Param("shotId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"reference": reference}) + }) + r.DELETE("/projects/:id/shots/:shotId/assets/:referenceId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + if err := svc.UnlinkShotAsset(user.ID, c.Param("id"), c.Param("shotId"), c.Param("referenceId")); err != nil { + failService(c, err) + return + } + ok(c, gin.H{"unlinked": true}) + }) + r.POST("/projects/:id/asset-candidates", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 512<<10) + var req service.CreateAssetCandidatesRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + candidates, err := svc.CreateProjectAssetCandidates(user.ID, c.Param("id"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"candidates": candidates}) + }) + r.GET("/projects/:id/asset-candidates", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, err := parsePositiveQueryInt(c.Query("page"), 1) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + pageSize, err := parsePositiveQueryInt(c.Query("pageSize"), 100) + if err != nil { + fail(c, http.StatusBadRequest, err) + return + } + result, err := svc.ProjectAssetCandidatesPage(user.ID, c.Param("id"), page, pageSize, c.Query("unitId"), c.Query("status"), c.Query("category"), c.Query("q")) + if err != nil { + failService(c, err) + return + } + ok(c, result) + }) + r.POST("/projects/:id/asset-candidates/:candidateId/confirm", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.ConfirmProjectAssetCandidateRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + asset, err := svc.ConfirmProjectAssetCandidate(user.ID, c.Param("id"), c.Param("candidateId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, gin.H{"asset": asset}) + }) +} diff --git a/backend/internal/protocol/builtin.go b/backend/internal/protocol/builtin.go index ddbd3e1f7..2f6323a33 100644 --- a/backend/internal/protocol/builtin.go +++ b/backend/internal/protocol/builtin.go @@ -21,12 +21,14 @@ type builtinAdapter struct { } func (a builtinAdapter) Metadata() Metadata { return a.info } + func (a builtinAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) { if a.create == nil { return RequestSpec{}, unavailable(a.info) } return a.create(c.Request) } + func (a builtinAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) { if a.parseCreate == nil { return CreateResult{}, unavailable(a.info) @@ -37,12 +39,14 @@ func (a builtinAdapter) ParseCreate(_ context.Context, body []byte) (CreateResul } return a.parseCreate(payload) } + func (a builtinAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) { if a.poll == nil { return RequestSpec{}, unavailable(a.info) } return a.poll(c) } + func (a builtinAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) { if a.parsePoll == nil { return PollResult{}, unavailable(a.info) @@ -53,6 +57,7 @@ func (a builtinAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) } return a.parsePoll(c, payload) } + func (a builtinAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) { if a.cancel == nil { return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.info.ID) @@ -61,6 +66,7 @@ func (a builtinAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSp } var builtinRegistry *Registry + var builtinRegistryMu sync.Mutex // Builtins returns the immutable process-wide registry of shipped protocol plugins. @@ -402,299 +408,6 @@ func miniMaxVideosAdapter() Adapter { }) } -func agnesAdapter() Adapter { - info := metadata("agnes-video", "Agnes 视频", "Agnes AI", CapabilityVideo, "POST /v1/videos", "GET /agnesapi?video_id={id}&model_name={model}", "application/json") - info.Version = "1.2.0" - info.Description = "Agnes AI 官方异步视频协议,支持 Agnes Video V2.0、2.5 与 2.5 Flash。" - info.RequiresPublicMediaURLs = true - info.Parameters = []Parameter{ - {Name: "model", Type: "string", Required: true, Mapping: "model", Description: "官方模型 ID:agnes-video-v2.0、agnes-video-2.5 或 agnes-video-2.5-flash。"}, - {Name: "prompt", Type: "string", Required: true, Mapping: "prompt", Description: "视频生成提示词。"}, - {Name: "images", Type: "media[]", Mapping: "first_frame/last_frame/images/image/extra_body.image", Description: "2.5 系列按 mode 映射为关键帧或参考图;V2.0 映射为 image 或关键帧数组。"}, - {Name: "videos", Type: "media[]", Mapping: "videos", Description: "仅 Agnes Video 2.5 reference 模式支持,Flash 与 V2.0 不支持。"}, - {Name: "audios", Type: "media[]", Mapping: "audios", Description: "Agnes Video 2.5 系列 reference 模式支持。"}, - {Name: "duration", Type: "integer", Mapping: "seconds/num_frames", Description: "2.5 系列映射为字符串 seconds;V2.0 按帧率换算 num_frames。"}, - {Name: "aspectRatio", Type: "string", Values: []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}, Mapping: "aspect_ratio", Description: "2.5 系列画幅比例;历史像素尺寸会在插件边界转换为对应比例。"}, - {Name: "resolution", Type: "string", Values: []string{"720P", "960P", "2K"}, Mapping: "size", Description: "通用层分辨率档位,映射为 Agnes 的 size;Flash 固定 720P。"}, - } - return builtinAdapter{ - info: info, - create: buildAgnesCreate, - parseCreate: parseAgnesCreate, - poll: func(c PollContext) (RequestSpec, error) { - model := strings.TrimSpace(c.Model) - if model == "" { - model = strings.TrimSpace(c.Request.Model) - } - path := "/agnesapi?video_id=" + url.QueryEscape(c.TaskID) - if model != "" { - path += "&model_name=" + url.QueryEscape(model) - } - return RequestSpec{Method: http.MethodGet, Path: path, OriginPath: true}, nil - }, - parsePoll: parseAgnesPoll, - } -} - -func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) { - model := strings.TrimSpace(r.Model) - switch model { - case "agnes-video-v2.0": - body, err := buildAgnesV20Body(r) - if err != nil { - return RequestSpec{}, err - } - return jsonSpec(http.MethodPost, "/v1/videos", body), nil - case "agnes-video-2.5", "agnes-video-2.5-flash": - body, err := buildAgnesV25Body(r) - if err != nil { - return RequestSpec{}, err - } - return jsonSpec(http.MethodPost, "/v1/videos", body), nil - default: - return RequestSpec{}, fmt.Errorf("Agnes 不支持模型 %q", model) - } -} - -func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { - frameImages, referenceImages, _ := splitVideoImages(r.Images) - if len(frameImages) > 0 && (len(referenceImages) > 0 || len(r.Videos)+len(r.Audios) > 0) { - return nil, fmt.Errorf("Agnes 2.5 不能同时混用首尾帧和角色、视频或音频参考素材") - } - mode := firstString(r.Extra, "mode") - if mode == "" { - switch strings.TrimSpace(r.Operation) { - case "reference_to_video", "audio_to_video": - mode = "reference" - case "image_to_video": - mode = "keyframe" - default: - switch { - case len(r.Videos) > 0 || len(r.Audios) > 0 || len(referenceImages) > 0: - mode = "reference" - case len(frameImages) > 0: - mode = "keyframe" - case len(r.Images) > 2: - mode = "reference" - case len(r.Images) > 0: - mode = "keyframe" - default: - mode = "text" - } - } - } - if mode != "text" && mode != "keyframe" && mode != "reference" { - return nil, fmt.Errorf("Agnes 2.5 mode 必须是 text、keyframe 或 reference") - } - seconds := defaultInt(r.Duration, 5) - if seconds < 4 || seconds > 12 { - return nil, fmt.Errorf("Agnes 2.5 seconds 必须在 4 到 12 秒之间") - } - size, err := normalizeAgnesV25Resolution(defaultValue(r.Resolution, firstString(r.Extra, "size"))) - if err != nil { - return nil, err - } - aspectRatio, err := normalizeAgnesV25AspectRatio(defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio"))) - if err != nil { - return nil, err - } - if r.Model == "agnes-video-2.5-flash" { - if size != "720P" { - return nil, fmt.Errorf("Agnes Video 2.5 Flash 仅支持 720P") - } - if len(r.Images) > 5 { - return nil, fmt.Errorf("Agnes Video 2.5 Flash 最多支持 5 张参考图") - } - if len(r.Videos) > 0 { - return nil, fmt.Errorf("Agnes Video 2.5 Flash 不支持参考视频") - } - } else if size != "720P" && size != "960P" && size != "2K" { - return nil, fmt.Errorf("Agnes Video 2.5 size 必须是 720P、960P 或 2K") - } - body := map[string]any{ - "model": r.Model, - "prompt": r.Prompt, - "mode": mode, - "seconds": strconv.Itoa(seconds), - "size": size, - "aspect_ratio": aspectRatio, - "n": 1, - } - if seed, ok := r.Extra["seed"]; ok { - body["seed"] = seed - } - switch mode { - case "text": - if len(r.Images)+len(r.Videos)+len(r.Audios) > 0 { - return nil, fmt.Errorf("Agnes 2.5 text 模式不接受参考媒体") - } - case "keyframe": - if len(r.Images) == 0 || len(r.Images) > 2 || len(r.Videos)+len(r.Audios) > 0 { - return nil, fmt.Errorf("Agnes 2.5 keyframe 模式需要 1 到 2 张图片,且不接受音视频参考") - } - _, referenceImages, _ := splitVideoImages(r.Images) - if len(referenceImages) > 0 { - return nil, fmt.Errorf("Agnes 2.5 keyframe 模式不能混用角色或风格参考图") - } - frameImages := orderedFrameImages(r.Images) - if len(frameImages) == 0 { - return nil, fmt.Errorf("Agnes 2.5 keyframe 模式缺少首帧图片") - } - body["first_frame"] = mediaValue(frameImages[0]) - if len(frameImages) > 1 { - body["last_frame"] = mediaValue(frameImages[1]) - } - case "reference": - if len(r.Images)+len(r.Videos)+len(r.Audios) == 0 { - return nil, fmt.Errorf("Agnes 2.5 reference 模式至少需要一种参考媒体") - } - if len(r.Images) > 0 { - body["images"] = mediaValues(r.Images) - } - if len(r.Audios) > 0 { - body["audios"] = mediaValues(r.Audios) - } - if len(r.Videos) > 0 { - videos := make([]any, 0, len(r.Videos)) - for _, video := range r.Videos { - videos = append(videos, map[string]any{"url": mediaValue(video)}) - } - body["videos"] = videos - } - } - return compactMap(body), nil -} - -func normalizeAgnesV25Resolution(value string) (string, error) { - switch strings.ToLower(strings.TrimSpace(value)) { - case "", "auto", "720", "720p": - return "720P", nil - case "960", "960p": - return "960P", nil - case "2k", "1440", "1440p": - return "2K", nil - default: - return "", fmt.Errorf("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K") - } -} - -func normalizeAgnesV25AspectRatio(value string) (string, error) { - normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) - if normalized == "" || normalized == "auto" { - return "16:9", nil - } - ratios := []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} - for _, ratio := range ratios { - if normalized == ratio { - return ratio, nil - } - } - dimensions := strings.Split(normalized, "x") - if len(dimensions) == 2 { - width, widthErr := strconv.ParseInt(dimensions[0], 10, 64) - height, heightErr := strconv.ParseInt(dimensions[1], 10, 64) - if widthErr == nil && heightErr == nil && width > 0 && height > 0 && width <= 1_000_000 && height <= 1_000_000 { - for _, ratio := range ratios { - parts := strings.Split(ratio, ":") - ratioWidth, _ := strconv.ParseInt(parts[0], 10, 64) - ratioHeight, _ := strconv.ParseInt(parts[1], 10, 64) - if width*ratioHeight == height*ratioWidth { - return ratio, nil - } - } - } - } - return "", fmt.Errorf("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16") -} - -func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) { - if len(r.Videos)+len(r.Audios) > 0 { - return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频") - } - _, referenceImages, _ := splitVideoImages(r.Images) - if r.Operation == "reference_to_video" || len(referenceImages) > 0 { - return nil, fmt.Errorf("Agnes Video V2.0 不支持角色或风格参考图模式,请改用 Agnes Video 2.5") - } - body := map[string]any{"model": r.Model, "prompt": r.Prompt} - mergeExtra(body, r.Extra, "mode", "width", "height", "num_frames", "frame_rate", "num_inference_steps", "seed", "negative_prompt") - if r.Duration > 0 { - frameRate := 24 - if value, ok := r.Extra["frame_rate"].(float64); ok && value >= 1 && value <= 60 { - frameRate = int(value) - } - if value, ok := r.Extra["frame_rate"].(int); ok && value >= 1 && value <= 60 { - frameRate = value - } - if _, supplied := r.Extra["frame_rate"]; !supplied { - body["frame_rate"] = frameRate - } - if _, supplied := r.Extra["num_frames"]; !supplied { - target := r.Duration * frameRate - frames := ((target-1+4)/8)*8 + 1 - if frames > 441 { - frames = 441 - } - body["num_frames"] = frames - } - } - orderedImages := orderedFrameImages(r.Images) - if len(orderedImages) == 1 { - body["image"] = mediaValue(orderedImages[0]) - } else if len(orderedImages) > 1 { - extraBody := map[string]any{} - if configured := object(r.Extra["extra_body"]); configured != nil { - for key, value := range configured { - extraBody[key] = value - } - } - extraBody["image"] = mediaValues(orderedImages) - extraBody["mode"] = "keyframes" - body["extra_body"] = extraBody - } else if configured := object(r.Extra["extra_body"]); configured != nil { - body["extra_body"] = configured - } - return compactMap(body), nil -} - -func parseAgnesCreate(payload map[string]any) (CreateResult, error) { - id := firstString(payload, "video_id", "task_id", "id") - if id == "" { - return CreateResult{}, fmt.Errorf("Agnes 创建响应没有 video_id") - } - status := normalizeStatus(firstString(payload, "status")) - if status == "" { - status = StatusPending - } - return CreateResult{TaskID: id, Status: status}, nil -} - -func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) { - id := defaultValue(firstString(payload, "video_id", "task_id", "id"), c.TaskID) - status := normalizeStatus(firstString(payload, "status")) - if status == "" { - status = StatusProcessing - } - message := firstString(payload, "message", "detail") - if failure := object(payload["error"]); failure != nil { - message = defaultValue(firstString(failure, "message", "detail", "code"), message) - } - if status == StatusFailed { - return PollResult{TaskID: id, Status: status, Message: defaultValue(message, "Agnes 视频生成失败")}, nil - } - if status != StatusSucceeded { - return PollResult{TaskID: id, Status: status, Message: message}, nil - } - metadata := object(payload["metadata"]) - videoURL := firstString(metadata, "url") - if videoURL == "" { - videoURL = firstString(payload, "url") - } - if videoURL == "" { - return PollResult{}, fmt.Errorf("Agnes 完成响应没有返回视频 URL") - } - return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil -} - func videoAdapter(info Metadata, create func(GenerationRequest) (RequestSpec, error)) Adapter { return asyncMediaAdapterWithPoll(info, CapabilityVideo, create, func(c PollContext) (RequestSpec, error) { path := strings.TrimSpace(info.Poll) @@ -807,6 +520,7 @@ func parseJiMengCreate(payload map[string]any) (CreateResult, error) { } return CreateResult{TaskID: id, Status: StatusPending}, nil } + func parseJiMengPoll(c PollContext, payload map[string]any) (PollResult, error) { return parseAsyncMediaPoll(c, payload, CapabilityImage) } @@ -965,7 +679,9 @@ func decodeObject(body []byte) (map[string]any, error) { } return payload, nil } + func object(value any) map[string]any { result, _ := value.(map[string]any); return result } + func mergeMaps(primary, fallback map[string]any) map[string]any { result := make(map[string]any, len(fallback)+len(primary)) for k, v := range fallback { @@ -976,6 +692,7 @@ func mergeMaps(primary, fallback map[string]any) map[string]any { } return result } + func firstString(payload map[string]any, keys ...string) string { for _, key := range keys { if value, ok := payload[key].(string); ok && strings.TrimSpace(value) != "" { @@ -984,23 +701,27 @@ func firstString(payload map[string]any, keys ...string) string { } return "" } + func defaultValue(value, fallback string) string { if strings.TrimSpace(value) == "" { return fallback } return value } + func defaultInt(value, fallback int) int { if value <= 0 { return fallback } return value } + func copyIf(body map[string]any, key, value string) { if strings.TrimSpace(value) != "" { body[key] = value } } + func mergeExtra(body map[string]any, extra map[string]any, keys ...string) { for _, key := range keys { if value, ok := extra[key]; ok { @@ -1008,6 +729,7 @@ func mergeExtra(body map[string]any, extra map[string]any, keys ...string) { } } } + func compactMap(body map[string]any) map[string]any { result := make(map[string]any) for key, value := range body { @@ -1029,7 +751,9 @@ func compactMap(body map[string]any) map[string]any { } return result } + func mediaValue(media MediaReference) string { return defaultValue(media.URL, media.DataURL) } + func mediaRole(media MediaReference, fallback string) string { return defaultValue(strings.TrimSpace(media.Role), fallback) } @@ -1080,6 +804,7 @@ func mediaValues(values []MediaReference) []string { } return result } + func mediaFromArray(value any, capability Capability) []MediaReference { values, _ := value.([]any) result := make([]MediaReference, 0, len(values)) @@ -1106,12 +831,14 @@ func mediaFromArray(value any, capability Capability) []MediaReference { } return result } + func dataMIME(value string) string { if index := strings.Index(value, ";"); strings.HasPrefix(value, "data:") && index > 5 { return strings.TrimPrefix(value[5:index], "") } return "application/octet-stream" } + func dataPayload(value string) string { if index := strings.Index(value, ","); strings.HasPrefix(value, "data:") && index >= 0 { return value[index+1:] diff --git a/backend/internal/protocol/builtin_agnes.go b/backend/internal/protocol/builtin_agnes.go new file mode 100644 index 000000000..6b4863f8e --- /dev/null +++ b/backend/internal/protocol/builtin_agnes.go @@ -0,0 +1,304 @@ +// Agnes 视频模型的内置适配:v2.0 / v2.5 两代请求体与分辨率、比例规范化。 + +package protocol + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "strings" +) + +func agnesAdapter() Adapter { + info := metadata("agnes-video", "Agnes 视频", "Agnes AI", CapabilityVideo, "POST /v1/videos", "GET /agnesapi?video_id={id}&model_name={model}", "application/json") + info.Version = "1.2.0" + info.Description = "Agnes AI 官方异步视频协议,支持 Agnes Video V2.0、2.5 与 2.5 Flash。" + info.RequiresPublicMediaURLs = true + info.Parameters = []Parameter{ + {Name: "model", Type: "string", Required: true, Mapping: "model", Description: "官方模型 ID:agnes-video-v2.0、agnes-video-2.5 或 agnes-video-2.5-flash。"}, + {Name: "prompt", Type: "string", Required: true, Mapping: "prompt", Description: "视频生成提示词。"}, + {Name: "images", Type: "media[]", Mapping: "first_frame/last_frame/images/image/extra_body.image", Description: "2.5 系列按 mode 映射为关键帧或参考图;V2.0 映射为 image 或关键帧数组。"}, + {Name: "videos", Type: "media[]", Mapping: "videos", Description: "仅 Agnes Video 2.5 reference 模式支持,Flash 与 V2.0 不支持。"}, + {Name: "audios", Type: "media[]", Mapping: "audios", Description: "Agnes Video 2.5 系列 reference 模式支持。"}, + {Name: "duration", Type: "integer", Mapping: "seconds/num_frames", Description: "2.5 系列映射为字符串 seconds;V2.0 按帧率换算 num_frames。"}, + {Name: "aspectRatio", Type: "string", Values: []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}, Mapping: "aspect_ratio", Description: "2.5 系列画幅比例;历史像素尺寸会在插件边界转换为对应比例。"}, + {Name: "resolution", Type: "string", Values: []string{"720P", "960P", "2K"}, Mapping: "size", Description: "通用层分辨率档位,映射为 Agnes 的 size;Flash 固定 720P。"}, + } + return builtinAdapter{ + info: info, + create: buildAgnesCreate, + parseCreate: parseAgnesCreate, + poll: func(c PollContext) (RequestSpec, error) { + model := strings.TrimSpace(c.Model) + if model == "" { + model = strings.TrimSpace(c.Request.Model) + } + path := "/agnesapi?video_id=" + url.QueryEscape(c.TaskID) + if model != "" { + path += "&model_name=" + url.QueryEscape(model) + } + return RequestSpec{Method: http.MethodGet, Path: path, OriginPath: true}, nil + }, + parsePoll: parseAgnesPoll, + } +} + +func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) { + model := strings.TrimSpace(r.Model) + switch model { + case "agnes-video-v2.0": + body, err := buildAgnesV20Body(r) + if err != nil { + return RequestSpec{}, err + } + return jsonSpec(http.MethodPost, "/v1/videos", body), nil + case "agnes-video-2.5", "agnes-video-2.5-flash": + body, err := buildAgnesV25Body(r) + if err != nil { + return RequestSpec{}, err + } + return jsonSpec(http.MethodPost, "/v1/videos", body), nil + default: + return RequestSpec{}, fmt.Errorf("Agnes 不支持模型 %q", model) + } +} + +func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { + frameImages, referenceImages, _ := splitVideoImages(r.Images) + if len(frameImages) > 0 && (len(referenceImages) > 0 || len(r.Videos)+len(r.Audios) > 0) { + return nil, fmt.Errorf("Agnes 2.5 不能同时混用首尾帧和角色、视频或音频参考素材") + } + mode := firstString(r.Extra, "mode") + if mode == "" { + switch strings.TrimSpace(r.Operation) { + case "reference_to_video", "audio_to_video": + mode = "reference" + case "image_to_video": + mode = "keyframe" + default: + switch { + case len(r.Videos) > 0 || len(r.Audios) > 0 || len(referenceImages) > 0: + mode = "reference" + case len(frameImages) > 0: + mode = "keyframe" + case len(r.Images) > 2: + mode = "reference" + case len(r.Images) > 0: + mode = "keyframe" + default: + mode = "text" + } + } + } + if mode != "text" && mode != "keyframe" && mode != "reference" { + return nil, fmt.Errorf("Agnes 2.5 mode 必须是 text、keyframe 或 reference") + } + seconds := defaultInt(r.Duration, 5) + if seconds < 4 || seconds > 12 { + return nil, fmt.Errorf("Agnes 2.5 seconds 必须在 4 到 12 秒之间") + } + size, err := normalizeAgnesV25Resolution(defaultValue(r.Resolution, firstString(r.Extra, "size"))) + if err != nil { + return nil, err + } + aspectRatio, err := normalizeAgnesV25AspectRatio(defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio"))) + if err != nil { + return nil, err + } + if r.Model == "agnes-video-2.5-flash" { + if size != "720P" { + return nil, fmt.Errorf("Agnes Video 2.5 Flash 仅支持 720P") + } + if len(r.Images) > 5 { + return nil, fmt.Errorf("Agnes Video 2.5 Flash 最多支持 5 张参考图") + } + if len(r.Videos) > 0 { + return nil, fmt.Errorf("Agnes Video 2.5 Flash 不支持参考视频") + } + } else if size != "720P" && size != "960P" && size != "2K" { + return nil, fmt.Errorf("Agnes Video 2.5 size 必须是 720P、960P 或 2K") + } + body := map[string]any{ + "model": r.Model, + "prompt": r.Prompt, + "mode": mode, + "seconds": strconv.Itoa(seconds), + "size": size, + "aspect_ratio": aspectRatio, + "n": 1, + } + if seed, ok := r.Extra["seed"]; ok { + body["seed"] = seed + } + switch mode { + case "text": + if len(r.Images)+len(r.Videos)+len(r.Audios) > 0 { + return nil, fmt.Errorf("Agnes 2.5 text 模式不接受参考媒体") + } + case "keyframe": + if len(r.Images) == 0 || len(r.Images) > 2 || len(r.Videos)+len(r.Audios) > 0 { + return nil, fmt.Errorf("Agnes 2.5 keyframe 模式需要 1 到 2 张图片,且不接受音视频参考") + } + _, referenceImages, _ := splitVideoImages(r.Images) + if len(referenceImages) > 0 { + return nil, fmt.Errorf("Agnes 2.5 keyframe 模式不能混用角色或风格参考图") + } + frameImages := orderedFrameImages(r.Images) + if len(frameImages) == 0 { + return nil, fmt.Errorf("Agnes 2.5 keyframe 模式缺少首帧图片") + } + body["first_frame"] = mediaValue(frameImages[0]) + if len(frameImages) > 1 { + body["last_frame"] = mediaValue(frameImages[1]) + } + case "reference": + if len(r.Images)+len(r.Videos)+len(r.Audios) == 0 { + return nil, fmt.Errorf("Agnes 2.5 reference 模式至少需要一种参考媒体") + } + if len(r.Images) > 0 { + body["images"] = mediaValues(r.Images) + } + if len(r.Audios) > 0 { + body["audios"] = mediaValues(r.Audios) + } + if len(r.Videos) > 0 { + videos := make([]any, 0, len(r.Videos)) + for _, video := range r.Videos { + videos = append(videos, map[string]any{"url": mediaValue(video)}) + } + body["videos"] = videos + } + } + return compactMap(body), nil +} + +func normalizeAgnesV25Resolution(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "auto", "720", "720p": + return "720P", nil + case "960", "960p": + return "960P", nil + case "2k", "1440", "1440p": + return "2K", nil + default: + return "", fmt.Errorf("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K") + } +} + +func normalizeAgnesV25AspectRatio(value string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) + if normalized == "" || normalized == "auto" { + return "16:9", nil + } + ratios := []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} + for _, ratio := range ratios { + if normalized == ratio { + return ratio, nil + } + } + dimensions := strings.Split(normalized, "x") + if len(dimensions) == 2 { + width, widthErr := strconv.ParseInt(dimensions[0], 10, 64) + height, heightErr := strconv.ParseInt(dimensions[1], 10, 64) + if widthErr == nil && heightErr == nil && width > 0 && height > 0 && width <= 1_000_000 && height <= 1_000_000 { + for _, ratio := range ratios { + parts := strings.Split(ratio, ":") + ratioWidth, _ := strconv.ParseInt(parts[0], 10, 64) + ratioHeight, _ := strconv.ParseInt(parts[1], 10, 64) + if width*ratioHeight == height*ratioWidth { + return ratio, nil + } + } + } + } + return "", fmt.Errorf("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16") +} + +func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) { + if len(r.Videos)+len(r.Audios) > 0 { + return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频") + } + _, referenceImages, _ := splitVideoImages(r.Images) + if r.Operation == "reference_to_video" || len(referenceImages) > 0 { + return nil, fmt.Errorf("Agnes Video V2.0 不支持角色或风格参考图模式,请改用 Agnes Video 2.5") + } + body := map[string]any{"model": r.Model, "prompt": r.Prompt} + mergeExtra(body, r.Extra, "mode", "width", "height", "num_frames", "frame_rate", "num_inference_steps", "seed", "negative_prompt") + if r.Duration > 0 { + frameRate := 24 + if value, ok := r.Extra["frame_rate"].(float64); ok && value >= 1 && value <= 60 { + frameRate = int(value) + } + if value, ok := r.Extra["frame_rate"].(int); ok && value >= 1 && value <= 60 { + frameRate = value + } + if _, supplied := r.Extra["frame_rate"]; !supplied { + body["frame_rate"] = frameRate + } + if _, supplied := r.Extra["num_frames"]; !supplied { + target := r.Duration * frameRate + frames := ((target-1+4)/8)*8 + 1 + if frames > 441 { + frames = 441 + } + body["num_frames"] = frames + } + } + orderedImages := orderedFrameImages(r.Images) + if len(orderedImages) == 1 { + body["image"] = mediaValue(orderedImages[0]) + } else if len(orderedImages) > 1 { + extraBody := map[string]any{} + if configured := object(r.Extra["extra_body"]); configured != nil { + for key, value := range configured { + extraBody[key] = value + } + } + extraBody["image"] = mediaValues(orderedImages) + extraBody["mode"] = "keyframes" + body["extra_body"] = extraBody + } else if configured := object(r.Extra["extra_body"]); configured != nil { + body["extra_body"] = configured + } + return compactMap(body), nil +} + +func parseAgnesCreate(payload map[string]any) (CreateResult, error) { + id := firstString(payload, "video_id", "task_id", "id") + if id == "" { + return CreateResult{}, fmt.Errorf("Agnes 创建响应没有 video_id") + } + status := normalizeStatus(firstString(payload, "status")) + if status == "" { + status = StatusPending + } + return CreateResult{TaskID: id, Status: status}, nil +} + +func parseAgnesPoll(c PollContext, payload map[string]any) (PollResult, error) { + id := defaultValue(firstString(payload, "video_id", "task_id", "id"), c.TaskID) + status := normalizeStatus(firstString(payload, "status")) + if status == "" { + status = StatusProcessing + } + message := firstString(payload, "message", "detail") + if failure := object(payload["error"]); failure != nil { + message = defaultValue(firstString(failure, "message", "detail", "code"), message) + } + if status == StatusFailed { + return PollResult{TaskID: id, Status: status, Message: defaultValue(message, "Agnes 视频生成失败")}, nil + } + if status != StatusSucceeded { + return PollResult{TaskID: id, Status: status, Message: message}, nil + } + metadata := object(payload["metadata"]) + videoURL := firstString(metadata, "url") + if videoURL == "" { + videoURL = firstString(payload, "url") + } + if videoURL == "" { + return PollResult{}, fmt.Errorf("Agnes 完成响应没有返回视频 URL") + } + return PollResult{TaskID: id, Status: status, Result: &Result{Videos: []MediaReference{{URL: videoURL, Kind: string(CapabilityVideo)}}}}, nil +} diff --git a/backend/internal/protocol/manifest.go b/backend/internal/protocol/manifest.go index 5f3d30d3c..2a09b198a 100644 --- a/backend/internal/protocol/manifest.go +++ b/backend/internal/protocol/manifest.go @@ -2,14 +2,10 @@ package protocol import ( "context" - "crypto/sha256" - "encoding/base64" "encoding/json" "fmt" "net/http" "net/url" - "regexp" - "strconv" "strings" ) @@ -162,29 +158,37 @@ type metadataAdapter struct { } func (a metadataAdapter) Metadata() Metadata { return a.metadata } + func (a metadataAdapter) AgentAvailable() bool { capability, ok := a.delegate.(AgentCapability) return ok && capability.AgentAvailable() } + func (a metadataAdapter) ResultAvailable() bool { capability, ok := a.delegate.(ResultCapability) return ok && capability.ResultAvailable() } + func (a metadataAdapter) BuildCreate(ctx context.Context, c RequestContext) (RequestSpec, error) { return a.delegate.BuildCreate(ctx, c) } + func (a metadataAdapter) ParseCreate(ctx context.Context, body []byte) (CreateResult, error) { return a.delegate.ParseCreate(ctx, body) } + func (a metadataAdapter) BuildPoll(ctx context.Context, c PollContext) (RequestSpec, error) { return a.delegate.BuildPoll(ctx, c) } + func (a metadataAdapter) ParsePoll(ctx context.Context, c PollContext, body []byte) (PollResult, error) { return a.delegate.ParsePoll(ctx, c, body) } + func (a metadataAdapter) BuildCancel(ctx context.Context, c PollContext) (RequestSpec, error) { return a.delegate.BuildCancel(ctx, c) } + func (a metadataAdapter) BuildResult(ctx context.Context, c PollContext) (RequestSpec, error) { adapter, ok := a.delegate.(ResultAdapter) if !ok { @@ -192,6 +196,7 @@ func (a metadataAdapter) BuildResult(ctx context.Context, c PollContext) (Reques } return adapter.BuildResult(ctx, c) } + func (a metadataAdapter) BuildAgent(ctx context.Context, c AgentRequestContext) (RequestSpec, error) { adapter, ok := a.delegate.(AgentAdapter) if !ok { @@ -199,6 +204,7 @@ func (a metadataAdapter) BuildAgent(ctx context.Context, c AgentRequestContext) } return adapter.BuildAgent(ctx, c) } + func (a metadataAdapter) ParseAgent(ctx context.Context, body []byte) (AgentResult, error) { adapter, ok := a.delegate.(AgentAdapter) if !ok { @@ -453,890 +459,6 @@ func validateManifestOperation(operation ManifestOperation) error { return nil } -type manifestAdapter struct{ manifest Manifest } - -func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata } -func (a manifestAdapter) AgentAvailable() bool { - return a.manifest.Agent != nil && a.manifest.AgentResponse != nil -} -func (a manifestAdapter) ResultAvailable() bool { return a.manifest.ResultOperation != nil } -func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) { - if len(a.manifest.Contributes.Providers) == 0 { - return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID) - } - if err := validateManifestRequest(a.manifest.Validations, c.Request); err != nil { - return RequestSpec{}, err - } - return buildManifestOperation(a.manifest.Create, a.manifest.Auth, c.Request, "") -} -func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) { - if a.manifest.Agent == nil { - return RequestSpec{}, fmt.Errorf("protocol %s has no agent operation", a.manifest.Metadata.ID) - } - request := GenerationRequest{Model: c.Model, Extra: map[string]any{"agent": c.Request}} - return buildManifestOperation(*a.manifest.Agent, a.manifest.Auth, request, "") -} -func (a manifestAdapter) ParseAgent(_ context.Context, body []byte) (AgentResult, error) { - if a.manifest.AgentResponse == nil { - return AgentResult{}, fmt.Errorf("protocol %s has no agent response mapping", a.manifest.Metadata.ID) - } - payload, err := decodeObject(body) - if err != nil { - return AgentResult{}, err - } - response := a.manifest.AgentResponse - result := AgentResult{Text: firstPathValue(payload, response.TextPaths...), Reasoning: firstPathValue(payload, response.ReasoningPaths...)} - if response.ToolCallsPath == "" { - return result, nil - } - for index, item := range arrayValue(pathValue(payload, response.ToolCallsPath)) { - object, ok := item.(map[string]any) - if !ok { - continue - } - call := AgentToolCall{ - ID: firstPathValue(object, response.ToolCallIDPaths...), - Name: firstPathValue(object, response.ToolCallNamePaths...), - Arguments: firstPathJSONValue(object, response.ToolCallArgsPaths...), - ThoughtSignature: firstPathValue(object, response.ToolCallSignaturePaths...), - } - if strings.TrimSpace(call.Name) != "" { - if strings.TrimSpace(call.ID) == "" { - call.ID = syntheticAgentToolCallID(body, index) - } - result.ToolCalls = append(result.ToolCalls, call) - } - } - return result, nil -} - -func syntheticAgentToolCallID(body []byte, index int) string { - sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", body, index))) - return fmt.Sprintf("call_%x", sum[:8]) -} -func (a manifestAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) { - if a.manifest.Response.BinaryPayload { - return binaryPayloadCreateResult(a.manifest.Response.ResultKind, body) - } - payload, err := decodeObject(body) - if err != nil { - return CreateResult{}, err - } - return a.parse(payload, PollContext{}), nil -} - -// binaryPayloadCreateResult 把同步二进制响应包装为单个媒体结果。MIME 以响应内容探测为准, -// 空响应必须失败,不能把空内容伪装成生成成功。 -func binaryPayloadCreateResult(resultKind string, body []byte) (CreateResult, error) { - if len(body) == 0 { - return CreateResult{}, fmt.Errorf("binary payload response is empty") - } - mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(body), ";")[0])) - reference := MediaReference{DataURL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(body), MIMEType: mimeType} - result := &Result{} - switch resultKind { - case "audio": - result.Audios = []MediaReference{reference} - case "image": - result.Images = []MediaReference{reference} - case "video": - result.Videos = []MediaReference{reference} - default: - return CreateResult{}, fmt.Errorf("binary payload response requires resultKind image, video or audio, got %q", resultKind) - } - return CreateResult{Status: StatusSucceeded, Result: result}, nil -} -func (a manifestAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) { - if a.manifest.Poll == nil { - return RequestSpec{}, fmt.Errorf("protocol %s has no poll operation", a.manifest.Metadata.ID) - } - request := c.Request - request.Model = c.Model - return buildManifestOperation(*a.manifest.Poll, a.manifest.Auth, request, c.TaskID) -} -func (a manifestAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) { - payload, err := decodeObject(body) - if err != nil { - return PollResult{}, err - } - result := a.parse(payload, c) - return PollResult{TaskID: result.TaskID, Status: result.Status, Result: result.Result, Message: result.Message}, nil -} -func (a manifestAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) { - if a.manifest.Cancel == nil { - return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.manifest.Metadata.ID) - } - request := c.Request - request.Model = c.Model - return buildManifestOperation(*a.manifest.Cancel, a.manifest.Auth, request, c.TaskID) -} - -func (a manifestAdapter) BuildResult(_ context.Context, c PollContext) (RequestSpec, error) { - if a.manifest.ResultOperation == nil { - return RequestSpec{}, fmt.Errorf("protocol %s has no result operation", a.manifest.Metadata.ID) - } - request := c.Request - request.Model = c.Model - return buildManifestOperation(*a.manifest.ResultOperation, a.manifest.Auth, request, c.TaskID) -} - -func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResult { - response := a.manifest.Response - env := map[string]any{"response": payload, "taskId": c.TaskID, "request": manifestRequestValues(c.Request)} - id := manifestResponseString(response.TaskID, env) - if id == "" { - id = firstPathValue(payload, response.TaskIDPaths...) - } - if id == "" { - id = c.TaskID - } - statusText := manifestResponseString(response.Status, env) - if statusText == "" { - statusText = firstPathValue(payload, response.StatusPaths...) - } - status := normalizeStatus(statusText) - if status == "" { - status = StatusPending - } - message := manifestResponseString(response.Message, env) - if message == "" { - message = firstPathValue(payload, response.MessagePaths...) - } - if manifestError(payload, response.ErrorPaths...) { - status = StatusFailed - } - result := &Result{ - Text: manifestResponseString(response.Text, env), - Reasoning: manifestResponseString(response.Reasoning, env), - } - if result.Text == "" { - result.Text = firstPathValue(payload, response.TextPaths...) - } - if result.Reasoning == "" { - result.Reasoning = firstPathValue(payload, response.ReasoningPaths...) - } - result.Images = manifestResponseMedia(response.Images, env, "image", response.ResultEphemeral) - result.Videos = manifestResponseMedia(response.Videos, env, "video", response.ResultEphemeral) - result.Audios = manifestResponseMedia(response.Audios, env, "audio", response.ResultEphemeral) - if response.Usage != nil { - if value, err := evaluateManifestValue(response.Usage, env); err == nil { - result.Usage = manifestObject(value) - } - } - paths := append([]string{}, a.manifest.Response.ResultURLPaths...) - paths = append(paths, a.manifest.Response.ResultPaths...) - for _, path := range paths { - for _, value := range mediaPathValues(payload, path) { - item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral} - switch a.manifest.Response.ResultKind { - case "image": - result.Images = append(result.Images, item) - case "audio": - result.Audios = append(result.Audios, item) - default: - result.Videos = append(result.Videos, item) - } - } - } - if status == StatusPending && (result.Text != "" || len(result.Images) > 0 || len(result.Videos) > 0 || len(result.Audios) > 0) { - status = StatusSucceeded - } - if result.Text == "" && result.Reasoning == "" && len(result.Images) == 0 && len(result.Videos) == 0 && len(result.Audios) == 0 { - result = nil - } - return CreateResult{TaskID: id, Status: status, Result: result, Message: message} -} - -func validateManifestRequest(rules []ManifestValidation, request GenerationRequest) error { - if len(rules) == 0 { - return nil - } - env := map[string]any{"request": manifestRequestValues(request)} - for _, rule := range rules { - value, err := evaluateManifestValue(rule.Assert, env) - if err != nil { - return fmt.Errorf("协议参数校验表达式错误:%w", err) - } - if !manifestTruthy(value) { - return fmt.Errorf("%s", strings.TrimSpace(rule.Message)) - } - } - return nil -} - -func manifestResponseString(template any, env map[string]any) string { - if template == nil { - return "" - } - value, err := evaluateManifestValue(template, env) - if err != nil { - return "" - } - items := manifestArray(value) - parts := make([]string, 0, len(items)) - for _, item := range items { - if text := strings.TrimSpace(manifestString(item)); text != "" { - parts = append(parts, text) - } - } - return strings.Join(parts, "") -} - -func manifestResponseMedia(template any, env map[string]any, kind string, ephemeral bool) []MediaReference { - if template == nil { - return nil - } - value, err := evaluateManifestValue(template, env) - if err != nil { - return nil - } - return mediaReferencesFromManifestValue(value, kind, ephemeral) -} - -func mediaReferencesFromManifestValue(value any, kind string, ephemeral bool) []MediaReference { - result := make([]MediaReference, 0) - for _, item := range manifestArray(value) { - switch typed := item.(type) { - case string: - trimmed := strings.TrimSpace(typed) - if trimmed == "" { - continue - } - reference := MediaReference{Kind: kind, Ephemeral: ephemeral} - if strings.HasPrefix(trimmed, "data:") { - reference.DataURL = trimmed - } else { - reference.URL = trimmed - } - result = append(result, reference) - case map[string]any: - reference := MediaReference{ - ID: manifestString(typed["id"]), URL: manifestString(typed["url"]), DataURL: manifestString(typed["dataUrl"]), - Kind: defaultValue(manifestString(typed["kind"]), kind), Role: manifestString(typed["role"]), MIMEType: manifestString(typed["mimeType"]), - Name: manifestString(typed["name"]), Order: manifestInt(typed["order"]), Weight: manifestFloat(typed["weight"]), Ephemeral: ephemeral || manifestTruthy(typed["ephemeral"]), - } - if reference.URL == "" { - reference.URL = firstString(typed, "file_url", "fileUrl", "image_url", "imageUrl", "video_url", "videoUrl", "audio_url", "audioUrl", "uri") - } - if reference.DataURL == "" { - reference.DataURL = firstString(typed, "data_url", "b64_json") - } - // OpenAI / Ark 等渠道常直接返回裸 b64_json;声明式结果下载要求 data URL。 - reference.DataURL = normalizeManifestInlineDataURL(reference.DataURL, reference.Kind, firstString(typed, "output_format", "mime_type", "mimeType")) - if reference.URL != "" || reference.DataURL != "" { - result = append(result, reference) - } - } - } - return result -} - -func normalizeManifestInlineDataURL(value, kind, formatHint string) string { - value = strings.TrimSpace(value) - if value == "" || strings.HasPrefix(value, "data:") { - return value - } - return "data:" + manifestInlineMediaMIME(kind, formatHint) + ";base64," + value -} - -func manifestInlineMediaMIME(kind, formatHint string) string { - hint := strings.ToLower(strings.TrimSpace(formatHint)) - switch hint { - case "image/png", "image/jpeg", "image/webp", "image/gif", "audio/mpeg", "audio/wav", "audio/ogg", "video/mp4", "video/webm": - return hint - case "image/jpg": - return "image/jpeg" - case "audio/mp3": - return "audio/mpeg" - case "png": - return "image/png" - case "jpeg", "jpg": - return "image/jpeg" - case "webp": - return "image/webp" - case "gif": - return "image/gif" - case "mp3", "mpeg": - return "audio/mpeg" - case "wav": - return "audio/wav" - case "mp4": - return "video/mp4" - case "webm": - return "video/webm" - } - switch strings.ToLower(strings.TrimSpace(kind)) { - case "audio": - return "audio/mpeg" - case "video": - return "video/mp4" - default: - return "image/png" - } -} - -var ( - manifestMDImageRegex = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) - manifestHTMLVideoRegex = regexp.MustCompile(`(?i)]+src=['"]([^'"]+)['"]`) -) - -func mediaPathValues(payload map[string]any, path string) []string { - value := pathValue(payload, path) - if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { - trimmed := strings.TrimSpace(text) - if matches := manifestMDImageRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 { - var res []string - for _, m := range matches { - if len(m) > 1 && m[1] != "" { - res = append(res, m[1]) - } - } - if len(res) > 0 { - return res - } - } - if matches := manifestHTMLVideoRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 { - var res []string - for _, m := range matches { - if len(m) > 1 && m[1] != "" { - res = append(res, m[1]) - } - } - if len(res) > 0 { - return res - } - } - return []string{trimmed} - } - items, ok := value.([]any) - if !ok { - return nil - } - values := make([]string, 0, len(items)) - for _, item := range items { - switch typed := item.(type) { - case string: - if strings.TrimSpace(typed) != "" { - values = append(values, strings.TrimSpace(typed)) - } - case map[string]any: - if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" { - values = append(values, url) - } - } - } - return values -} - -func buildManifestOperation(operation ManifestOperation, auth ManifestAuth, request GenerationRequest, taskID string) (RequestSpec, error) { - requestValues := manifestRequestValues(request) - env := map[string]any{"request": requestValues, "taskId": taskID} - var body any - if operation.Body != nil { - value, err := evaluateManifestValue(operation.Body, env) - if err != nil { - return RequestSpec{}, fmt.Errorf("evaluate request body: %w", err) - } - body = normalizeManifestValue(value) - } else if len(operation.Fields) > 0 { - legacyBody := make(map[string]any, len(operation.Fields)) - for key, expression := range operation.Fields { - value := manifestExpressionValue(expression, request, taskID) - if value == nil { - continue - } - setMapPath(legacyBody, key, value) - } - body = normalizeManifestValue(legacyBody) - } - pathValue := any(operation.Path) - if operation.PathTemplate != nil { - pathValue = operation.PathTemplate - } - evaluatedPath, err := evaluateManifestValue(pathValue, env) - if err != nil { - return RequestSpec{}, fmt.Errorf("evaluate request path: %w", err) - } - path := strings.ReplaceAll(manifestString(evaluatedPath), "{{taskId}}", url.PathEscape(taskID)) - // Model identifiers from async aggregators commonly contain path segments - // (for example openai/gpt-image/edit). Escape each segment while preserving - // the provider's intentional slash separators in the manifest path. - escapedModel := strings.ReplaceAll(url.PathEscape(request.Model), "%2F", "/") - path = strings.ReplaceAll(path, "{{model}}", escapedModel) - path = interpolateManifestString(path, env) - if !isRelativePath(path) { - return RequestSpec{}, fmt.Errorf("evaluated request path must be relative: %q", path) - } - headers, err := evaluateManifestStringMap(operation.Headers, env) - if err != nil { - return RequestSpec{}, fmt.Errorf("evaluate request headers: %w", err) - } - query, err := evaluateManifestQuery(operation.Query, env) - if err != nil { - return RequestSpec{}, fmt.Errorf("evaluate request query: %w", err) - } - files, err := evaluateManifestFiles(operation.Files, env) - if err != nil { - return RequestSpec{}, err - } - contentTypeValue := any(operation.ContentType) - if operation.ContentTypeTemplate != nil { - contentTypeValue = operation.ContentTypeTemplate - } - evaluatedContentType, err := evaluateManifestValue(contentTypeValue, env) - if err != nil { - return RequestSpec{}, fmt.Errorf("evaluate request content type: %w", err) - } - contentType := defaultValue(manifestString(evaluatedContentType), "application/json") - return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, OriginPath: operation.OriginPath, ContentType: contentType, Headers: headers, Query: query, Body: body, Files: files, Auth: auth}, nil -} - -func evaluateManifestStringMap(values map[string]any, env map[string]any) (map[string]string, error) { - if len(values) == 0 { - return nil, nil - } - result := make(map[string]string, len(values)) - for key, template := range values { - value, err := evaluateManifestValue(template, env) - if err != nil { - return nil, err - } - if text := strings.TrimSpace(manifestString(value)); text != "" { - result[key] = text - } - } - return result, nil -} - -func evaluateManifestQuery(values map[string]any, env map[string]any) (map[string][]string, error) { - if len(values) == 0 { - return nil, nil - } - result := make(map[string][]string, len(values)) - for key, template := range values { - value, err := evaluateManifestValue(template, env) - if err != nil { - return nil, err - } - for _, item := range manifestArray(value) { - if text := strings.TrimSpace(manifestString(item)); text != "" { - result[key] = append(result[key], text) - } - } - } - return result, nil -} - -func evaluateManifestFiles(parts []ManifestFilePart, env map[string]any) ([]RequestFilePart, error) { - result := make([]RequestFilePart, 0) - for _, part := range parts { - source, err := evaluateManifestValue(part.Source, env) - if err != nil { - return nil, fmt.Errorf("evaluate multipart file %q: %w", part.Name, err) - } - filename, err := evaluateManifestValue(part.Filename, env) - if err != nil { - return nil, fmt.Errorf("evaluate multipart filename %q: %w", part.Name, err) - } - mimeType, err := evaluateManifestValue(part.MIMEType, env) - if err != nil { - return nil, fmt.Errorf("evaluate multipart MIME type %q: %w", part.Name, err) - } - for index, value := range manifestArray(source) { - references := mediaReferencesFromManifestValue(value, "file", false) - for _, reference := range references { - name := strings.TrimSpace(manifestString(filename)) - if name == "" { - name = reference.Name - } - if name == "" { - name = fmt.Sprintf("%s-%d", part.Name, index+1) - } - contentType := strings.TrimSpace(manifestString(mimeType)) - if contentType == "" { - contentType = reference.MIMEType - } - result = append(result, RequestFilePart{Name: part.Name, Filename: name, MIMEType: contentType, Reference: reference}) - } - } - } - return result, nil -} - -func normalizeManifestValue(value any) any { - switch v := value.(type) { - case map[string]any: - if len(v) == 0 { - return v - } - isSequential := true - for i := 0; i < len(v); i++ { - if _, ok := v[strconv.Itoa(i)]; !ok { - isSequential = false - break - } - } - if isSequential { - arr := make([]any, len(v)) - for i := 0; i < len(v); i++ { - arr[i] = normalizeManifestValue(v[strconv.Itoa(i)]) - } - return arr - } - res := make(map[string]any, len(v)) - for k, val := range v { - res[k] = normalizeManifestValue(val) - } - return res - case []any: - arr := make([]any, len(v)) - for i, val := range v { - arr[i] = normalizeManifestValue(val) - } - return arr - default: - return value - } -} - -func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any { - expression = strings.TrimSpace(expression) - source := expression - transforms := []string{} - if separator := strings.IndexByte(expression, '|'); separator >= 0 { - source = strings.TrimSpace(expression[:separator]) - transforms = strings.Split(expression[separator+1:], "|") - } - - var value any - switch { - case source == "taskId": - value = taskID - case strings.HasPrefix(source, "request."): - value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request.")) - default: - value = source - } - for _, transform := range transforms { - value = applyManifestTransform(value, transform, request) - if value == nil { - break - } - } - return value -} - -// manifestRequestValues is deliberately a small, JSON-shaped view of the -// platform request. It lets uploaded manifests address media items by index -// without exposing Go structs or adding provider-specific host code. -func manifestRequestValues(request GenerationRequest) map[string]any { - userContent := make([]any, 0) - if strings.TrimSpace(request.Prompt) != "" { - userContent = append(userContent, map[string]any{"type": "text", "text": request.Prompt}) - } - for _, img := range request.Images { - val := defaultValue(img.URL, img.DataURL) - if val != "" { - userContent = append(userContent, map[string]any{"type": "image_url", "image_url": map[string]any{"url": val}}) - } - } - for _, vid := range request.Videos { - val := defaultValue(vid.URL, vid.DataURL) - if val != "" { - userContent = append(userContent, map[string]any{"type": "video_url", "video_url": map[string]any{"url": val}}) - } - } - for _, aud := range request.Audios { - val := defaultValue(aud.URL, aud.DataURL) - if val != "" { - userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}}) - } - } - messages := make([]any, 0, len(request.Messages)+2) - // instructions 是统一的系统指令字段,但多数 OpenAI 系协议只映射 request.messages。 - // 系统指令必须进入消息数组,否则会被静默丢弃;带独立 system 字段的协议(Claude、 - // Gemini、Responses)在各自模板里过滤 system 角色,不会重复发送。 - if instructions := strings.TrimSpace(request.Instructions); instructions != "" && !hasSystemMessage(request.Messages) { - messages = append(messages, map[string]any{"role": "system", "content": instructions}) - } - for _, message := range request.Messages { - if strings.TrimSpace(message.Role) == "" || message.Content == nil { - continue - } - messages = append(messages, map[string]any{"role": message.Role, "content": message.Content}) - } - if len(userContent) > 0 { - content := any(userContent) - if len(userContent) == 1 && len(request.Images)+len(request.Videos)+len(request.Audios) == 0 { - content = request.Prompt - } - messages = append(messages, map[string]any{"role": "user", "content": content}) - } - - inputs := request.Inputs - if len(inputs) == 0 { - inputs = append(inputs, request.Images...) - inputs = append(inputs, request.Videos...) - inputs = append(inputs, request.Audios...) - } - output := request.Output - if output.Count == 0 { - output.Count = request.ImageCount - } - if output.Duration == 0 { - output.Duration = request.Duration - } - if output.AspectRatio == "" { - output.AspectRatio = request.AspectRatio - } - if output.Resolution == "" { - output.Resolution = request.Resolution - } - if output.Quality == "" { - output.Quality = request.Quality - } - output.GenerateAudio = output.GenerateAudio || request.GenerateAudio - output.Watermark = output.Watermark || request.Watermark - outputValue, _ := requestAsManifestValue(output) - providerOptionsValue, _ := requestAsManifestValue(request.ProviderOptions) - if providerOptionsValue == nil { - providerOptionsValue = map[string]any{} - } - - return map[string]any{ - "capability": request.Capability, - "model": request.Model, - "prompt": request.Prompt, - "instructions": request.Instructions, - "messages": messages, - "inputs": manifestMediaValues(inputs), - "images": manifestMediaValues(request.Images), - "videos": manifestMediaValues(request.Videos), - "audios": manifestMediaValues(request.Audios), - "imageCount": request.ImageCount, - "duration": request.Duration, - "aspectRatio": request.AspectRatio, - "resolution": request.Resolution, - "quality": request.Quality, - "generateAudio": request.GenerateAudio, - "watermark": request.Watermark, - "operation": request.Operation, - "output": outputValue, - "providerOptions": providerOptionsValue, - "extra": request.Extra, - } -} - -func hasSystemMessage(messages []Message) bool { - for _, message := range messages { - if strings.EqualFold(strings.TrimSpace(message.Role), "system") { - return true - } - } - return false -} - -func requestAsManifestValue(value any) (any, error) { - encoded, err := json.Marshal(value) - if err != nil { - return nil, err - } - var result any - if err := json.Unmarshal(encoded, &result); err != nil { - return nil, err - } - return result, nil -} - -func manifestModelID(request GenerationRequest) string { - modelID := strings.TrimSpace(request.Model) - if separator := strings.LastIndex(modelID, "::"); separator >= 0 { - modelID = modelID[separator+2:] - } - return strings.ToLower(modelID) -} - -func manifestMediaValues(values []MediaReference) []any { - result := make([]any, 0, len(values)) - for index, value := range values { - order := value.Order - if order == 0 && index > 0 { - order = index - } - resolved := defaultValue(value.URL, value.DataURL) - result = append(result, map[string]any{ - "id": value.ID, - "url": value.URL, - "dataUrl": value.DataURL, - "value": resolved, - "kind": value.Kind, - "role": value.Role, - "mimeType": value.MIMEType, - "name": value.Name, - "order": order, - "weight": value.Weight, - "metadata": value.Metadata, - "source": map[string]any{ - "type": manifestMediaSourceType(value), - "value": resolved, - "mimeType": value.MIMEType, - }, - "ephemeral": value.Ephemeral, - }) - } - return result -} - -func manifestMediaSourceType(value MediaReference) string { - if value.DataURL != "" { - return "data" - } - if value.URL != "" { - return "url" - } - return "unknown" -} - -func applyManifestTransform(value any, transform string, request GenerationRequest) any { - transform = strings.ToLower(strings.TrimSpace(transform)) - if transform == "bool" || transform == "boolean" { - switch v := value.(type) { - case bool: - return v - case string: - s := strings.ToLower(strings.TrimSpace(v)) - return s == "true" || s == "1" || s == "yes" - case int, int64, float64: - return v != 0 - default: - return false - } - } - if transform == "int" || transform == "integer" { - switch v := value.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case string: - if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { - return n - } - return 0 - default: - return 0 - } - } - if transform == "omit_zero" { - switch v := value.(type) { - case int: - if v == 0 { - return nil - } - case int64: - if v == 0 { - return nil - } - case float64: - if v == 0 { - return nil - } - case string: - if strings.TrimSpace(v) == "0" || strings.TrimSpace(v) == "" { - return nil - } - } - } - cleanModel := manifestModelID(request) - if strings.HasPrefix(transform, "omit_unless_model_contains:") { - needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_contains:"))) - if needle == "" || !strings.Contains(cleanModel, needle) { - return nil - } - } - if strings.HasPrefix(transform, "omit_if_model_contains:") { - needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_contains:"))) - if needle != "" && strings.Contains(cleanModel, needle) { - return nil - } - } - if strings.HasPrefix(transform, "omit_unless_model_equals:") { - target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_equals:"))) - if target == "" || cleanModel != target { - return nil - } - } - if strings.HasPrefix(transform, "omit_if_model_equals:") { - target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_equals:"))) - if target != "" && cleanModel == target { - return nil - } - } - text, ok := value.(string) - if transform == "omit_empty" && (!ok || strings.TrimSpace(text) == "") { - return nil - } - if transform == "omit_auto" && (!ok || strings.TrimSpace(text) == "" || strings.EqualFold(strings.TrimSpace(text), "auto") || strings.EqualFold(strings.TrimSpace(text), "default")) { - return nil - } - if !ok { - return value - } - switch transform { - case "trim": - return strings.TrimSpace(text) - case "lower", "lowercase": - return strings.ToLower(text) - case "upper", "uppercase": - return strings.ToUpper(text) - case "resolution_p": - if regexp.MustCompile(`^\d+$`).MatchString(strings.TrimSpace(text)) { - return strings.TrimSpace(text) + "p" - } - return text - case "video-resolution", "video_resolution", "videoresolution": - // Compatibility for already-installed 1.0.1 manifests. New plugins - // should declare exact enum values and use generic string transforms. - return normalizeLegacyManifestVideoResolution(text) - default: - return value - } -} - -func normalizeLegacyManifestVideoResolution(value string) string { - normalized := strings.ToLower(strings.TrimSpace(value)) - if normalized == "" { - return "" - } - for _, char := range normalized { - if char < '0' || char > '9' { - return normalized - } - } - return normalized + "p" -} - -func pathValue(payload map[string]any, path string) any { - return manifestPathValue(payload, path) -} - -func arrayValue(value any) []any { - items, _ := value.([]any) - return items -} - -func firstPathValue(payload map[string]any, paths ...string) string { - for _, path := range paths { - value := pathValue(payload, path) - if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { - return strings.TrimSpace(text) - } - } - return "" -} - func manifestError(payload map[string]any, paths ...string) bool { for _, path := range paths { value := pathValue(payload, path) diff --git a/backend/internal/protocol/manifest_adapter.go b/backend/internal/protocol/manifest_adapter.go new file mode 100644 index 000000000..811b15514 --- /dev/null +++ b/backend/internal/protocol/manifest_adapter.go @@ -0,0 +1,400 @@ +// 声明式插件的运行期适配器(manifestAdapter):按清单组装请求、解析响应中的任务 ID、状态与媒体。 +// +// 媒体提取同时支持 JSON 路径、Markdown 图片、HTML 视频标签与内联 data URL, +// 兼容不同上游的返回形态。 + +package protocol + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "regexp" + "strings" +) + +type manifestAdapter struct{ manifest Manifest } + +func (a manifestAdapter) Metadata() Metadata { return a.manifest.Metadata } + +func (a manifestAdapter) AgentAvailable() bool { + return a.manifest.Agent != nil && a.manifest.AgentResponse != nil +} + +func (a manifestAdapter) ResultAvailable() bool { return a.manifest.ResultOperation != nil } + +func (a manifestAdapter) BuildCreate(_ context.Context, c RequestContext) (RequestSpec, error) { + if len(a.manifest.Contributes.Providers) == 0 { + return RequestSpec{}, fmt.Errorf("plugin %s does not provide a provider", a.manifest.Metadata.ID) + } + if err := validateManifestRequest(a.manifest.Validations, c.Request); err != nil { + return RequestSpec{}, err + } + return buildManifestOperation(a.manifest.Create, a.manifest.Auth, c.Request, "") +} + +func (a manifestAdapter) BuildAgent(_ context.Context, c AgentRequestContext) (RequestSpec, error) { + if a.manifest.Agent == nil { + return RequestSpec{}, fmt.Errorf("protocol %s has no agent operation", a.manifest.Metadata.ID) + } + request := GenerationRequest{Model: c.Model, Extra: map[string]any{"agent": c.Request}} + return buildManifestOperation(*a.manifest.Agent, a.manifest.Auth, request, "") +} + +func (a manifestAdapter) ParseAgent(_ context.Context, body []byte) (AgentResult, error) { + if a.manifest.AgentResponse == nil { + return AgentResult{}, fmt.Errorf("protocol %s has no agent response mapping", a.manifest.Metadata.ID) + } + payload, err := decodeObject(body) + if err != nil { + return AgentResult{}, err + } + response := a.manifest.AgentResponse + result := AgentResult{Text: firstPathValue(payload, response.TextPaths...), Reasoning: firstPathValue(payload, response.ReasoningPaths...)} + if response.ToolCallsPath == "" { + return result, nil + } + for index, item := range arrayValue(pathValue(payload, response.ToolCallsPath)) { + object, ok := item.(map[string]any) + if !ok { + continue + } + call := AgentToolCall{ + ID: firstPathValue(object, response.ToolCallIDPaths...), + Name: firstPathValue(object, response.ToolCallNamePaths...), + Arguments: firstPathJSONValue(object, response.ToolCallArgsPaths...), + ThoughtSignature: firstPathValue(object, response.ToolCallSignaturePaths...), + } + if strings.TrimSpace(call.Name) != "" { + if strings.TrimSpace(call.ID) == "" { + call.ID = syntheticAgentToolCallID(body, index) + } + result.ToolCalls = append(result.ToolCalls, call) + } + } + return result, nil +} + +func syntheticAgentToolCallID(body []byte, index int) string { + sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d", body, index))) + return fmt.Sprintf("call_%x", sum[:8]) +} + +func (a manifestAdapter) ParseCreate(_ context.Context, body []byte) (CreateResult, error) { + if a.manifest.Response.BinaryPayload { + return binaryPayloadCreateResult(a.manifest.Response.ResultKind, body) + } + payload, err := decodeObject(body) + if err != nil { + return CreateResult{}, err + } + return a.parse(payload, PollContext{}), nil +} + +// binaryPayloadCreateResult 把同步二进制响应包装为单个媒体结果。MIME 以响应内容探测为准, +// 空响应必须失败,不能把空内容伪装成生成成功。 +func binaryPayloadCreateResult(resultKind string, body []byte) (CreateResult, error) { + if len(body) == 0 { + return CreateResult{}, fmt.Errorf("binary payload response is empty") + } + mimeType := strings.ToLower(strings.TrimSpace(strings.Split(http.DetectContentType(body), ";")[0])) + reference := MediaReference{DataURL: "data:" + mimeType + ";base64," + base64.StdEncoding.EncodeToString(body), MIMEType: mimeType} + result := &Result{} + switch resultKind { + case "audio": + result.Audios = []MediaReference{reference} + case "image": + result.Images = []MediaReference{reference} + case "video": + result.Videos = []MediaReference{reference} + default: + return CreateResult{}, fmt.Errorf("binary payload response requires resultKind image, video or audio, got %q", resultKind) + } + return CreateResult{Status: StatusSucceeded, Result: result}, nil +} + +func (a manifestAdapter) BuildPoll(_ context.Context, c PollContext) (RequestSpec, error) { + if a.manifest.Poll == nil { + return RequestSpec{}, fmt.Errorf("protocol %s has no poll operation", a.manifest.Metadata.ID) + } + request := c.Request + request.Model = c.Model + return buildManifestOperation(*a.manifest.Poll, a.manifest.Auth, request, c.TaskID) +} + +func (a manifestAdapter) ParsePoll(_ context.Context, c PollContext, body []byte) (PollResult, error) { + payload, err := decodeObject(body) + if err != nil { + return PollResult{}, err + } + result := a.parse(payload, c) + return PollResult{TaskID: result.TaskID, Status: result.Status, Result: result.Result, Message: result.Message}, nil +} + +func (a manifestAdapter) BuildCancel(_ context.Context, c PollContext) (RequestSpec, error) { + if a.manifest.Cancel == nil { + return RequestSpec{}, fmt.Errorf("protocol %s does not support cancellation", a.manifest.Metadata.ID) + } + request := c.Request + request.Model = c.Model + return buildManifestOperation(*a.manifest.Cancel, a.manifest.Auth, request, c.TaskID) +} + +func (a manifestAdapter) BuildResult(_ context.Context, c PollContext) (RequestSpec, error) { + if a.manifest.ResultOperation == nil { + return RequestSpec{}, fmt.Errorf("protocol %s has no result operation", a.manifest.Metadata.ID) + } + request := c.Request + request.Model = c.Model + return buildManifestOperation(*a.manifest.ResultOperation, a.manifest.Auth, request, c.TaskID) +} + +func (a manifestAdapter) parse(payload map[string]any, c PollContext) CreateResult { + response := a.manifest.Response + env := map[string]any{"response": payload, "taskId": c.TaskID, "request": manifestRequestValues(c.Request)} + id := manifestResponseString(response.TaskID, env) + if id == "" { + id = firstPathValue(payload, response.TaskIDPaths...) + } + if id == "" { + id = c.TaskID + } + statusText := manifestResponseString(response.Status, env) + if statusText == "" { + statusText = firstPathValue(payload, response.StatusPaths...) + } + status := normalizeStatus(statusText) + if status == "" { + status = StatusPending + } + message := manifestResponseString(response.Message, env) + if message == "" { + message = firstPathValue(payload, response.MessagePaths...) + } + if manifestError(payload, response.ErrorPaths...) { + status = StatusFailed + } + result := &Result{ + Text: manifestResponseString(response.Text, env), + Reasoning: manifestResponseString(response.Reasoning, env), + } + if result.Text == "" { + result.Text = firstPathValue(payload, response.TextPaths...) + } + if result.Reasoning == "" { + result.Reasoning = firstPathValue(payload, response.ReasoningPaths...) + } + result.Images = manifestResponseMedia(response.Images, env, "image", response.ResultEphemeral) + result.Videos = manifestResponseMedia(response.Videos, env, "video", response.ResultEphemeral) + result.Audios = manifestResponseMedia(response.Audios, env, "audio", response.ResultEphemeral) + if response.Usage != nil { + if value, err := evaluateManifestValue(response.Usage, env); err == nil { + result.Usage = manifestObject(value) + } + } + paths := append([]string{}, a.manifest.Response.ResultURLPaths...) + paths = append(paths, a.manifest.Response.ResultPaths...) + for _, path := range paths { + for _, value := range mediaPathValues(payload, path) { + item := MediaReference{URL: value, Kind: a.manifest.Response.ResultKind, Ephemeral: a.manifest.Response.ResultEphemeral} + switch a.manifest.Response.ResultKind { + case "image": + result.Images = append(result.Images, item) + case "audio": + result.Audios = append(result.Audios, item) + default: + result.Videos = append(result.Videos, item) + } + } + } + if status == StatusPending && (result.Text != "" || len(result.Images) > 0 || len(result.Videos) > 0 || len(result.Audios) > 0) { + status = StatusSucceeded + } + if result.Text == "" && result.Reasoning == "" && len(result.Images) == 0 && len(result.Videos) == 0 && len(result.Audios) == 0 { + result = nil + } + return CreateResult{TaskID: id, Status: status, Result: result, Message: message} +} + +func validateManifestRequest(rules []ManifestValidation, request GenerationRequest) error { + if len(rules) == 0 { + return nil + } + env := map[string]any{"request": manifestRequestValues(request)} + for _, rule := range rules { + value, err := evaluateManifestValue(rule.Assert, env) + if err != nil { + return fmt.Errorf("协议参数校验表达式错误:%w", err) + } + if !manifestTruthy(value) { + return fmt.Errorf("%s", strings.TrimSpace(rule.Message)) + } + } + return nil +} + +func manifestResponseString(template any, env map[string]any) string { + if template == nil { + return "" + } + value, err := evaluateManifestValue(template, env) + if err != nil { + return "" + } + items := manifestArray(value) + parts := make([]string, 0, len(items)) + for _, item := range items { + if text := strings.TrimSpace(manifestString(item)); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "") +} + +func manifestResponseMedia(template any, env map[string]any, kind string, ephemeral bool) []MediaReference { + if template == nil { + return nil + } + value, err := evaluateManifestValue(template, env) + if err != nil { + return nil + } + return mediaReferencesFromManifestValue(value, kind, ephemeral) +} + +func mediaReferencesFromManifestValue(value any, kind string, ephemeral bool) []MediaReference { + result := make([]MediaReference, 0) + for _, item := range manifestArray(value) { + switch typed := item.(type) { + case string: + trimmed := strings.TrimSpace(typed) + if trimmed == "" { + continue + } + reference := MediaReference{Kind: kind, Ephemeral: ephemeral} + if strings.HasPrefix(trimmed, "data:") { + reference.DataURL = trimmed + } else { + reference.URL = trimmed + } + result = append(result, reference) + case map[string]any: + reference := MediaReference{ + ID: manifestString(typed["id"]), URL: manifestString(typed["url"]), DataURL: manifestString(typed["dataUrl"]), + Kind: defaultValue(manifestString(typed["kind"]), kind), Role: manifestString(typed["role"]), MIMEType: manifestString(typed["mimeType"]), + Name: manifestString(typed["name"]), Order: manifestInt(typed["order"]), Weight: manifestFloat(typed["weight"]), Ephemeral: ephemeral || manifestTruthy(typed["ephemeral"]), + } + if reference.URL == "" { + reference.URL = firstString(typed, "file_url", "fileUrl", "image_url", "imageUrl", "video_url", "videoUrl", "audio_url", "audioUrl", "uri") + } + if reference.DataURL == "" { + reference.DataURL = firstString(typed, "data_url", "b64_json") + } + // OpenAI / Ark 等渠道常直接返回裸 b64_json;声明式结果下载要求 data URL。 + reference.DataURL = normalizeManifestInlineDataURL(reference.DataURL, reference.Kind, firstString(typed, "output_format", "mime_type", "mimeType")) + if reference.URL != "" || reference.DataURL != "" { + result = append(result, reference) + } + } + } + return result +} + +func normalizeManifestInlineDataURL(value, kind, formatHint string) string { + value = strings.TrimSpace(value) + if value == "" || strings.HasPrefix(value, "data:") { + return value + } + return "data:" + manifestInlineMediaMIME(kind, formatHint) + ";base64," + value +} + +func manifestInlineMediaMIME(kind, formatHint string) string { + hint := strings.ToLower(strings.TrimSpace(formatHint)) + switch hint { + case "image/png", "image/jpeg", "image/webp", "image/gif", "audio/mpeg", "audio/wav", "audio/ogg", "video/mp4", "video/webm": + return hint + case "image/jpg": + return "image/jpeg" + case "audio/mp3": + return "audio/mpeg" + case "png": + return "image/png" + case "jpeg", "jpg": + return "image/jpeg" + case "webp": + return "image/webp" + case "gif": + return "image/gif" + case "mp3", "mpeg": + return "audio/mpeg" + case "wav": + return "audio/wav" + case "mp4": + return "video/mp4" + case "webm": + return "video/webm" + } + switch strings.ToLower(strings.TrimSpace(kind)) { + case "audio": + return "audio/mpeg" + case "video": + return "video/mp4" + default: + return "image/png" + } +} + +var ( + manifestMDImageRegex = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) + manifestHTMLVideoRegex = regexp.MustCompile(`(?i)]+src=['"]([^'"]+)['"]`) +) + +func mediaPathValues(payload map[string]any, path string) []string { + value := pathValue(payload, path) + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + trimmed := strings.TrimSpace(text) + if matches := manifestMDImageRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 { + var res []string + for _, m := range matches { + if len(m) > 1 && m[1] != "" { + res = append(res, m[1]) + } + } + if len(res) > 0 { + return res + } + } + if matches := manifestHTMLVideoRegex.FindAllStringSubmatch(trimmed, -1); len(matches) > 0 { + var res []string + for _, m := range matches { + if len(m) > 1 && m[1] != "" { + res = append(res, m[1]) + } + } + if len(res) > 0 { + return res + } + } + return []string{trimmed} + } + items, ok := value.([]any) + if !ok { + return nil + } + values := make([]string, 0, len(items)) + for _, item := range items { + switch typed := item.(type) { + case string: + if strings.TrimSpace(typed) != "" { + values = append(values, strings.TrimSpace(typed)) + } + case map[string]any: + if url := firstString(typed, "url", "file_url", "fileUrl", "video_url", "videoUrl", "image_url", "imageUrl"); url != "" { + values = append(values, url) + } + } + } + return values +} diff --git a/backend/internal/protocol/manifest_request.go b/backend/internal/protocol/manifest_request.go new file mode 100644 index 000000000..14a4543d1 --- /dev/null +++ b/backend/internal/protocol/manifest_request.go @@ -0,0 +1,522 @@ +// 声明式插件请求的求值:把清单里的表达式、查询、文件与变换(transform) +// 代入任务输入,得到最终请求。所有取值都经过白名单路径与类型规范化。 + +package protocol + +import ( + "encoding/json" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" +) + +func buildManifestOperation(operation ManifestOperation, auth ManifestAuth, request GenerationRequest, taskID string) (RequestSpec, error) { + requestValues := manifestRequestValues(request) + env := map[string]any{"request": requestValues, "taskId": taskID} + var body any + if operation.Body != nil { + value, err := evaluateManifestValue(operation.Body, env) + if err != nil { + return RequestSpec{}, fmt.Errorf("evaluate request body: %w", err) + } + body = normalizeManifestValue(value) + } else if len(operation.Fields) > 0 { + legacyBody := make(map[string]any, len(operation.Fields)) + for key, expression := range operation.Fields { + value := manifestExpressionValue(expression, request, taskID) + if value == nil { + continue + } + setMapPath(legacyBody, key, value) + } + body = normalizeManifestValue(legacyBody) + } + pathValue := any(operation.Path) + if operation.PathTemplate != nil { + pathValue = operation.PathTemplate + } + evaluatedPath, err := evaluateManifestValue(pathValue, env) + if err != nil { + return RequestSpec{}, fmt.Errorf("evaluate request path: %w", err) + } + path := strings.ReplaceAll(manifestString(evaluatedPath), "{{taskId}}", url.PathEscape(taskID)) + // Model identifiers from async aggregators commonly contain path segments + // (for example openai/gpt-image/edit). Escape each segment while preserving + // the provider's intentional slash separators in the manifest path. + escapedModel := strings.ReplaceAll(url.PathEscape(request.Model), "%2F", "/") + path = strings.ReplaceAll(path, "{{model}}", escapedModel) + path = interpolateManifestString(path, env) + if !isRelativePath(path) { + return RequestSpec{}, fmt.Errorf("evaluated request path must be relative: %q", path) + } + headers, err := evaluateManifestStringMap(operation.Headers, env) + if err != nil { + return RequestSpec{}, fmt.Errorf("evaluate request headers: %w", err) + } + query, err := evaluateManifestQuery(operation.Query, env) + if err != nil { + return RequestSpec{}, fmt.Errorf("evaluate request query: %w", err) + } + files, err := evaluateManifestFiles(operation.Files, env) + if err != nil { + return RequestSpec{}, err + } + contentTypeValue := any(operation.ContentType) + if operation.ContentTypeTemplate != nil { + contentTypeValue = operation.ContentTypeTemplate + } + evaluatedContentType, err := evaluateManifestValue(contentTypeValue, env) + if err != nil { + return RequestSpec{}, fmt.Errorf("evaluate request content type: %w", err) + } + contentType := defaultValue(manifestString(evaluatedContentType), "application/json") + return RequestSpec{Method: strings.ToUpper(operation.Method), Path: path, OriginPath: operation.OriginPath, ContentType: contentType, Headers: headers, Query: query, Body: body, Files: files, Auth: auth}, nil +} + +func evaluateManifestStringMap(values map[string]any, env map[string]any) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + result := make(map[string]string, len(values)) + for key, template := range values { + value, err := evaluateManifestValue(template, env) + if err != nil { + return nil, err + } + if text := strings.TrimSpace(manifestString(value)); text != "" { + result[key] = text + } + } + return result, nil +} + +func evaluateManifestQuery(values map[string]any, env map[string]any) (map[string][]string, error) { + if len(values) == 0 { + return nil, nil + } + result := make(map[string][]string, len(values)) + for key, template := range values { + value, err := evaluateManifestValue(template, env) + if err != nil { + return nil, err + } + for _, item := range manifestArray(value) { + if text := strings.TrimSpace(manifestString(item)); text != "" { + result[key] = append(result[key], text) + } + } + } + return result, nil +} + +func evaluateManifestFiles(parts []ManifestFilePart, env map[string]any) ([]RequestFilePart, error) { + result := make([]RequestFilePart, 0) + for _, part := range parts { + source, err := evaluateManifestValue(part.Source, env) + if err != nil { + return nil, fmt.Errorf("evaluate multipart file %q: %w", part.Name, err) + } + filename, err := evaluateManifestValue(part.Filename, env) + if err != nil { + return nil, fmt.Errorf("evaluate multipart filename %q: %w", part.Name, err) + } + mimeType, err := evaluateManifestValue(part.MIMEType, env) + if err != nil { + return nil, fmt.Errorf("evaluate multipart MIME type %q: %w", part.Name, err) + } + for index, value := range manifestArray(source) { + references := mediaReferencesFromManifestValue(value, "file", false) + for _, reference := range references { + name := strings.TrimSpace(manifestString(filename)) + if name == "" { + name = reference.Name + } + if name == "" { + name = fmt.Sprintf("%s-%d", part.Name, index+1) + } + contentType := strings.TrimSpace(manifestString(mimeType)) + if contentType == "" { + contentType = reference.MIMEType + } + result = append(result, RequestFilePart{Name: part.Name, Filename: name, MIMEType: contentType, Reference: reference}) + } + } + } + return result, nil +} + +func normalizeManifestValue(value any) any { + switch v := value.(type) { + case map[string]any: + if len(v) == 0 { + return v + } + isSequential := true + for i := 0; i < len(v); i++ { + if _, ok := v[strconv.Itoa(i)]; !ok { + isSequential = false + break + } + } + if isSequential { + arr := make([]any, len(v)) + for i := 0; i < len(v); i++ { + arr[i] = normalizeManifestValue(v[strconv.Itoa(i)]) + } + return arr + } + res := make(map[string]any, len(v)) + for k, val := range v { + res[k] = normalizeManifestValue(val) + } + return res + case []any: + arr := make([]any, len(v)) + for i, val := range v { + arr[i] = normalizeManifestValue(val) + } + return arr + default: + return value + } +} + +func manifestExpressionValue(expression string, request GenerationRequest, taskID string) any { + expression = strings.TrimSpace(expression) + source := expression + transforms := []string{} + if separator := strings.IndexByte(expression, '|'); separator >= 0 { + source = strings.TrimSpace(expression[:separator]) + transforms = strings.Split(expression[separator+1:], "|") + } + + var value any + switch { + case source == "taskId": + value = taskID + case strings.HasPrefix(source, "request."): + value = pathValue(manifestRequestValues(request), strings.TrimPrefix(source, "request.")) + default: + value = source + } + for _, transform := range transforms { + value = applyManifestTransform(value, transform, request) + if value == nil { + break + } + } + return value +} + +// manifestRequestValues is deliberately a small, JSON-shaped view of the +// platform request. It lets uploaded manifests address media items by index +// without exposing Go structs or adding provider-specific host code. +func manifestRequestValues(request GenerationRequest) map[string]any { + userContent := make([]any, 0) + if strings.TrimSpace(request.Prompt) != "" { + userContent = append(userContent, map[string]any{"type": "text", "text": request.Prompt}) + } + for _, img := range request.Images { + val := defaultValue(img.URL, img.DataURL) + if val != "" { + userContent = append(userContent, map[string]any{"type": "image_url", "image_url": map[string]any{"url": val}}) + } + } + for _, vid := range request.Videos { + val := defaultValue(vid.URL, vid.DataURL) + if val != "" { + userContent = append(userContent, map[string]any{"type": "video_url", "video_url": map[string]any{"url": val}}) + } + } + for _, aud := range request.Audios { + val := defaultValue(aud.URL, aud.DataURL) + if val != "" { + userContent = append(userContent, map[string]any{"type": "audio_url", "audio_url": map[string]any{"url": val}}) + } + } + messages := make([]any, 0, len(request.Messages)+2) + // instructions 是统一的系统指令字段,但多数 OpenAI 系协议只映射 request.messages。 + // 系统指令必须进入消息数组,否则会被静默丢弃;带独立 system 字段的协议(Claude、 + // Gemini、Responses)在各自模板里过滤 system 角色,不会重复发送。 + if instructions := strings.TrimSpace(request.Instructions); instructions != "" && !hasSystemMessage(request.Messages) { + messages = append(messages, map[string]any{"role": "system", "content": instructions}) + } + for _, message := range request.Messages { + if strings.TrimSpace(message.Role) == "" || message.Content == nil { + continue + } + messages = append(messages, map[string]any{"role": message.Role, "content": message.Content}) + } + if len(userContent) > 0 { + content := any(userContent) + if len(userContent) == 1 && len(request.Images)+len(request.Videos)+len(request.Audios) == 0 { + content = request.Prompt + } + messages = append(messages, map[string]any{"role": "user", "content": content}) + } + + inputs := request.Inputs + if len(inputs) == 0 { + inputs = append(inputs, request.Images...) + inputs = append(inputs, request.Videos...) + inputs = append(inputs, request.Audios...) + } + output := request.Output + if output.Count == 0 { + output.Count = request.ImageCount + } + if output.Duration == 0 { + output.Duration = request.Duration + } + if output.AspectRatio == "" { + output.AspectRatio = request.AspectRatio + } + if output.Resolution == "" { + output.Resolution = request.Resolution + } + if output.Quality == "" { + output.Quality = request.Quality + } + output.GenerateAudio = output.GenerateAudio || request.GenerateAudio + output.Watermark = output.Watermark || request.Watermark + outputValue, _ := requestAsManifestValue(output) + providerOptionsValue, _ := requestAsManifestValue(request.ProviderOptions) + if providerOptionsValue == nil { + providerOptionsValue = map[string]any{} + } + + return map[string]any{ + "capability": request.Capability, + "model": request.Model, + "prompt": request.Prompt, + "instructions": request.Instructions, + "messages": messages, + "inputs": manifestMediaValues(inputs), + "images": manifestMediaValues(request.Images), + "videos": manifestMediaValues(request.Videos), + "audios": manifestMediaValues(request.Audios), + "imageCount": request.ImageCount, + "duration": request.Duration, + "aspectRatio": request.AspectRatio, + "resolution": request.Resolution, + "quality": request.Quality, + "generateAudio": request.GenerateAudio, + "watermark": request.Watermark, + "operation": request.Operation, + "output": outputValue, + "providerOptions": providerOptionsValue, + "extra": request.Extra, + } +} + +func hasSystemMessage(messages []Message) bool { + for _, message := range messages { + if strings.EqualFold(strings.TrimSpace(message.Role), "system") { + return true + } + } + return false +} + +func requestAsManifestValue(value any) (any, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + var result any + if err := json.Unmarshal(encoded, &result); err != nil { + return nil, err + } + return result, nil +} + +func manifestModelID(request GenerationRequest) string { + modelID := strings.TrimSpace(request.Model) + if separator := strings.LastIndex(modelID, "::"); separator >= 0 { + modelID = modelID[separator+2:] + } + return strings.ToLower(modelID) +} + +func manifestMediaValues(values []MediaReference) []any { + result := make([]any, 0, len(values)) + for index, value := range values { + order := value.Order + if order == 0 && index > 0 { + order = index + } + resolved := defaultValue(value.URL, value.DataURL) + result = append(result, map[string]any{ + "id": value.ID, + "url": value.URL, + "dataUrl": value.DataURL, + "value": resolved, + "kind": value.Kind, + "role": value.Role, + "mimeType": value.MIMEType, + "name": value.Name, + "order": order, + "weight": value.Weight, + "metadata": value.Metadata, + "source": map[string]any{ + "type": manifestMediaSourceType(value), + "value": resolved, + "mimeType": value.MIMEType, + }, + "ephemeral": value.Ephemeral, + }) + } + return result +} + +func manifestMediaSourceType(value MediaReference) string { + if value.DataURL != "" { + return "data" + } + if value.URL != "" { + return "url" + } + return "unknown" +} + +func applyManifestTransform(value any, transform string, request GenerationRequest) any { + transform = strings.ToLower(strings.TrimSpace(transform)) + if transform == "bool" || transform == "boolean" { + switch v := value.(type) { + case bool: + return v + case string: + s := strings.ToLower(strings.TrimSpace(v)) + return s == "true" || s == "1" || s == "yes" + case int, int64, float64: + return v != 0 + default: + return false + } + } + if transform == "int" || transform == "integer" { + switch v := value.(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return n + } + return 0 + default: + return 0 + } + } + if transform == "omit_zero" { + switch v := value.(type) { + case int: + if v == 0 { + return nil + } + case int64: + if v == 0 { + return nil + } + case float64: + if v == 0 { + return nil + } + case string: + if strings.TrimSpace(v) == "0" || strings.TrimSpace(v) == "" { + return nil + } + } + } + cleanModel := manifestModelID(request) + if strings.HasPrefix(transform, "omit_unless_model_contains:") { + needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_contains:"))) + if needle == "" || !strings.Contains(cleanModel, needle) { + return nil + } + } + if strings.HasPrefix(transform, "omit_if_model_contains:") { + needle := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_contains:"))) + if needle != "" && strings.Contains(cleanModel, needle) { + return nil + } + } + if strings.HasPrefix(transform, "omit_unless_model_equals:") { + target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_unless_model_equals:"))) + if target == "" || cleanModel != target { + return nil + } + } + if strings.HasPrefix(transform, "omit_if_model_equals:") { + target := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(transform, "omit_if_model_equals:"))) + if target != "" && cleanModel == target { + return nil + } + } + text, ok := value.(string) + if transform == "omit_empty" && (!ok || strings.TrimSpace(text) == "") { + return nil + } + if transform == "omit_auto" && (!ok || strings.TrimSpace(text) == "" || strings.EqualFold(strings.TrimSpace(text), "auto") || strings.EqualFold(strings.TrimSpace(text), "default")) { + return nil + } + if !ok { + return value + } + switch transform { + case "trim": + return strings.TrimSpace(text) + case "lower", "lowercase": + return strings.ToLower(text) + case "upper", "uppercase": + return strings.ToUpper(text) + case "resolution_p": + if regexp.MustCompile(`^\d+$`).MatchString(strings.TrimSpace(text)) { + return strings.TrimSpace(text) + "p" + } + return text + case "video-resolution", "video_resolution", "videoresolution": + // Compatibility for already-installed 1.0.1 manifests. New plugins + // should declare exact enum values and use generic string transforms. + return normalizeLegacyManifestVideoResolution(text) + default: + return value + } +} + +func normalizeLegacyManifestVideoResolution(value string) string { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized == "" { + return "" + } + for _, char := range normalized { + if char < '0' || char > '9' { + return normalized + } + } + return normalized + "p" +} + +func pathValue(payload map[string]any, path string) any { + return manifestPathValue(payload, path) +} + +func arrayValue(value any) []any { + items, _ := value.([]any) + return items +} + +func firstPathValue(payload map[string]any, paths ...string) string { + for _, path := range paths { + value := pathValue(payload, path) + if text, ok := value.(string); ok && strings.TrimSpace(text) != "" { + return strings.TrimSpace(text) + } + } + return "" +} diff --git a/backend/internal/protocol/package.go b/backend/internal/protocol/package.go index 63725a1da..90f0fb5bd 100644 --- a/backend/internal/protocol/package.go +++ b/backend/internal/protocol/package.go @@ -28,9 +28,49 @@ type PluginPackage struct { Files map[string][]byte } +// PluginPackageInfo 是只读取清单、不解压其余文件的包摘要。 +// 它刻意不携带 Files:调用方无法误把"只校验过结构"的包内容写到磁盘或执行。 +type PluginPackageInfo struct { + Manifest Manifest + ManifestRaw []byte +} + // ParsePluginPackage validates the package container and returns its manifest // and files. Uploaded code is never executed by this function. func ParsePluginPackage(data []byte) (PluginPackage, error) { + return readPluginPackage(data, nil) +} + +// InspectPluginPackage 与 ParsePluginPackage 做同样的结构校验(路径白名单、文件数、 +// 单文件声明大小、符号链接、manifest 与运行时入口),但只解压 manifest.json。 +// +// 为什么需要它:官方支付插件每个包内含 7 个平台的可执行文件,解压后约 44MB; +// 服务启动(以及每个构造 Service 的测试)只需要清单即可完成注册。全量解压让每次 +// 启动多花约 4 秒,CI 中上百个测试累计 7 分钟以上。 +// +// 限制:未解压的条目只校验 zip 头声明的大小,不校验 CRC。整包完整性由调用方 +// 对原始字节计算 SHA-256 保证;真正需要落盘执行文件时仍必须走 ParsePluginPackage。 +func InspectPluginPackage(data []byte) (PluginPackageInfo, error) { + pkg, err := readPluginPackage(data, func(string) bool { return false }) + if err != nil { + return PluginPackageInfo{}, err + } + return PluginPackageInfo{Manifest: pkg.Manifest, ManifestRaw: pkg.ManifestRaw}, nil +} + +// ParsePluginPackageFiles 与 ParsePluginPackage 做完全相同的校验,但只解压 keep 返回 true +// 的条目(manifest.json 总会解压);其余条目在 Files 中值为 nil,仅用于存在性判断。 +// 被解压的条目仍会经过 zip 的 CRC 校验。 +func ParsePluginPackageFiles(data []byte, keep func(name string) bool) (PluginPackage, error) { + if keep == nil { + keep = func(string) bool { return false } + } + return readPluginPackage(data, keep) +} + +// readPluginPackage 是各种读取方式的共同实现。keep 为 nil 表示解压全部条目; +// 否则只解压 manifest.json 与 keep 选中的条目,其余只登记文件名(值为 nil)。 +func readPluginPackage(data []byte, keep func(name string) bool) (PluginPackage, error) { if len(data) == 0 || len(data) > PluginPackageMaxBytes { return PluginPackage{}, fmt.Errorf("plugin package must be between 1 and %d bytes", PluginPackageMaxBytes) } @@ -60,6 +100,10 @@ func ParsePluginPackage(data []byte) (PluginPackage, error) { if file.UncompressedSize64 > pluginPackageMaxEntry { return PluginPackage{}, fmt.Errorf("plugin package file %q exceeds %d bytes", name, pluginPackageMaxEntry) } + if keep != nil && name != "manifest.json" && !keep(name) { + files[name] = nil + continue + } stream, err := file.Open() if err != nil { return PluginPackage{}, fmt.Errorf("open plugin package file %q: %w", name, err) diff --git a/backend/internal/protocol/package_test.go b/backend/internal/protocol/package_test.go index 5e441c7d9..c8b56167f 100644 --- a/backend/internal/protocol/package_test.go +++ b/backend/internal/protocol/package_test.go @@ -69,6 +69,57 @@ func TestParsePluginPackageAcceptsTaggedRPCBackend(t *testing.T) { } } +// InspectPluginPackage 只解压清单:结构校验与 ParsePluginPackage 相同(路径白名单、运行时入口), +// 但返回值不携带文件内容,调用方无法把「只校验过结构」的可执行文件写盘。 +func TestInspectPluginPackageValidatesStructureWithoutContents(t *testing.T) { + manifest := []byte(`{ + "apiVersion":"yingce.plugin/v1", + "id":"tagged-payment", + "name":"Tagged Payment", + "version":"1.0.0", + "author":"Test", + "enabled":true, + "runtime":{"backend":"rpc","backendEntry":"backend/provider"}, + "contributes":{"paymentProviders":[{"id":"tagged-pay","label":"Tagged","icon":"brand:test","checkoutMode":"qr_code","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]} + }`) + info, err := InspectPluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "backend/provider-linux-amd64": []byte("binary")})) + if err != nil { + t.Fatal(err) + } + if info.Manifest.Metadata.ID != "tagged-payment" || len(info.ManifestRaw) == 0 { + t.Fatalf("info = %#v", info) + } + if _, err := InspectPluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest})); err == nil { + t.Fatal("rpc package without any backend artifact was accepted by inspection") + } + if _, err := InspectPluginPackage(zipPluginPackage(t, map[string][]byte{"manifest.json": manifest, "backend/provider": []byte("x"), "server/evil": []byte("x")})); err == nil { + t.Fatal("forbidden package path was accepted by inspection") + } +} + +// ParsePluginPackageFiles 只解压选中的条目;未选中的条目以 nil 登记,便于存在性判断。 +func TestParsePluginPackageFilesLoadsOnlySelectedEntries(t *testing.T) { + manifest := []byte(`{"apiVersion":"yingce.plugin/v1","id":"tagged-payment","name":"Tagged Payment","version":"1.0.0","author":"Test","enabled":true,"runtime":{"backend":"rpc","backendEntry":"backend/provider"},"contributes":{"paymentProviders":[{"id":"tagged-pay","label":"Tagged","icon":"brand:test","checkoutMode":"qr_code","expiryPolicy":{"defaultMinutes":30,"minMinutes":5,"maxMinutes":1440}}]}}`) + data := zipPluginPackage(t, map[string][]byte{ + "manifest.json": manifest, + "backend/provider-linux-amd64": []byte("linux"), + "backend/provider-darwin-arm64": []byte("darwin"), + }) + pkg, err := ParsePluginPackageFiles(data, func(name string) bool { return name == "backend/provider-linux-amd64" }) + if err != nil { + t.Fatal(err) + } + if string(pkg.Files["backend/provider-linux-amd64"]) != "linux" { + t.Fatalf("selected entry content = %q", pkg.Files["backend/provider-linux-amd64"]) + } + if content, listed := pkg.Files["backend/provider-darwin-arm64"]; !listed || content != nil { + t.Fatalf("unselected entry must be listed with nil content, got listed=%v content=%q", listed, content) + } + if len(pkg.ManifestRaw) == 0 { + t.Fatal("manifest must always be loaded") + } +} + func zipPluginPackage(t *testing.T, files map[string][]byte) []byte { t.Helper() var buffer bytes.Buffer diff --git a/backend/internal/protocol/payment_rpc.go b/backend/internal/protocol/payment_rpc.go index 7220f4645..389804db9 100644 --- a/backend/internal/protocol/payment_rpc.go +++ b/backend/internal/protocol/payment_rpc.go @@ -24,6 +24,12 @@ func PaymentRPCBackendCandidates(entry, goos, goarch string) []string { return uniqueStrings(candidates) } +// IsPaymentRPCBackendArtifact 判断包内文件是否是 entry 对应的某个平台的可执行文件 +// (entry 本身、entry--、/-/,以及它们的 .exe 形式)。 +func IsPaymentRPCBackendArtifact(entry, name string) bool { + return HasAnyPaymentRPCBackend(entry, map[string][]byte{name: nil}) +} + func HasAnyPaymentRPCBackend(entry string, files map[string][]byte) bool { entry = strings.TrimSuffix(strings.TrimSpace(entry), ".exe") if entry == "" { diff --git a/backend/internal/repository/finance.go b/backend/internal/repository/finance.go index 9e73eee6d..86f98a5f3 100644 --- a/backend/internal/repository/finance.go +++ b/backend/internal/repository/finance.go @@ -3,17 +3,12 @@ package repository import ( "crypto/rand" "encoding/hex" - "encoding/json" "errors" - "strconv" - "strings" "time" - "infinite-canvas/backend/internal/kernel" - "infinite-canvas/backend/internal/model" - "gorm.io/gorm" "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" ) var ( @@ -64,328 +59,6 @@ func (r *Repository) GrantCreditsOnce(userID string, entryType model.CreditLedge return &account, granted, err } -type AdminRedeemCodeRow struct { - model.RedeemCode - RedeemedUsername string `json:"redeemedUsername" gorm:"column:redeemed_username"` - RedeemedDisplayName string `json:"redeemedDisplayName" gorm:"column:redeemed_display_name"` -} - -func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) { - var items []model.ChannelModel - query := r.db.Where("channel_id = ?", channelID).Order("sort_order asc, created_at asc, id asc") - if !includeDisabled { - query = query.Where("enabled = ?", true) - } - if err := query.Find(&items).Error; err != nil { - return nil, err - } - pointers := make([]*model.ChannelModel, len(items)) - for index := range items { - pointers[index] = &items[index] - } - return items, r.attachChannelModelPriceTiers(pointers) -} - -func (r *Repository) ChannelModelReferences(channelIDs []string) ([]model.ChannelModel, error) { - if len(channelIDs) == 0 { - return []model.ChannelModel{}, nil - } - var items []model.ChannelModel - if err := r.db.Select("id", "channel_id", "model_key", "display_name", "sort_order", "enabled"). - Where("channel_id IN ?", channelIDs). - Order("channel_id asc, sort_order asc, created_at asc, id asc"). - Find(&items).Error; err != nil { - return nil, err - } - return items, nil -} - -func (r *Repository) CreateDuplicatedSystemChannel(channel *model.ModelChannel, channelModels []model.ChannelModel, priceTiers []model.ChannelModelPriceTier) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(channel).Error; err != nil { - return err - } - if len(channelModels) > 0 { - if err := tx.Create(&channelModels).Error; err != nil { - return err - } - } - if len(priceTiers) > 0 { - if err := tx.Create(&priceTiers).Error; err != nil { - return err - } - } - return nil - }) -} - -func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) { - var item model.ChannelModel - if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil { - return nil, err - } - if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { - return nil, err - } - return &item, nil -} - -func (r *Repository) ChannelModelByKey(channelID string, modelKey string) (*model.ChannelModel, error) { - var item model.ChannelModel - if err := r.db.First(&item, "channel_id = ? AND model_key = ? AND enabled = ?", channelID, modelKey, true).Error; err != nil { - return nil, err - } - if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { - return nil, err - } - return &item, nil -} - -func (r *Repository) ChannelModelByKeyIncludingDisabled(channelID string, modelKey string) (*model.ChannelModel, error) { - var item model.ChannelModel - if err := r.db.First(&item, "channel_id = ? AND model_key = ?", channelID, modelKey).Error; err != nil { - return nil, err - } - if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { - return nil, err - } - return &item, nil -} - -func (r *Repository) SaveChannelModel(item *model.ChannelModel) error { - return r.db.Save(item).Error -} - -// SaveChannelModelWithPriceTiers 原子保存系统模型与其活动价格档。移除价格档采用软删除, -// 让已结算订单的 PriceTierID 仍能回溯到原始配置版本。 -func (r *Repository) SaveChannelModelWithPriceTiers(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var existing []model.ChannelModelPriceTier - if err := tx.Where("channel_model_id = ?", item.ID).Find(&existing).Error; err != nil { - return err - } - existingByKey := make(map[string]model.ChannelModelPriceTier, len(existing)) - for _, tier := range existing { - existingByKey[channelModelPriceTierKey(tier)] = tier - } - selected := make(map[string]bool, len(tiers)) - for index := range tiers { - tier := &tiers[index] - tier.ChannelModelID = item.ID - key := channelModelPriceTierKey(*tier) - if existingTier, exists := existingByKey[key]; exists { - tier.ID = existingTier.ID - tier.PriceVersion = existingTier.PriceVersion + 1 - if err := tx.Save(tier).Error; err != nil { - return err - } - } else if err := tx.Create(tier).Error; err != nil { - return err - } - selected[tier.ID] = true - } - for _, tier := range existing { - if selected[tier.ID] { - continue - } - if err := tx.Delete(&tier).Error; err != nil { - return err - } - } - if err := tx.Save(item).Error; err != nil { - return err - } - return nil - }) -} - -func channelModelPriceTierKey(tier model.ChannelModelPriceTier) string { - if strings.TrimSpace(tier.SelectorKey) != "" { - return tier.SelectorKey - } - _, key, err := model.CanonicalSKUSelector(map[string]string{ - "vquality": strings.TrimSpace(tier.Resolution), - "videoSeconds": strconv.Itoa(tier.VideoSeconds), - }) - if err != nil { - return "{}" - } - return key -} - -func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) error { - if len(items) == 0 { - return nil - } - ids := make([]string, 0, len(items)) - for _, item := range items { - ids = append(ids, item.ID) - } - var tiers []model.ChannelModelPriceTier - if r.db.Migrator().HasTable(&model.ChannelModelPriceTier{}) { - if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil { - return err - } - } - for index := range tiers { - tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON) - } - tiersByModelID := make(map[string][]model.ChannelModelPriceTier, len(items)) - for _, tier := range tiers { - tiersByModelID[tier.ChannelModelID] = append(tiersByModelID[tier.ChannelModelID], tier) - } - for _, item := range items { - item.PriceTiers = tiersByModelID[item.ID] - // 兼容尚未执行价格档回填的旧数据库;正式迁移会将同一数据持久化为默认档。 - if len(item.PriceTiers) == 0 && item.PriceConfigured { - item.PriceTiers = []model.ChannelModelPriceTier{{ - ChannelModelID: item.ID, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*", - ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode, - UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, - OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, - PriceConfigured: item.PriceConfigured, Enabled: item.Enabled, PriceVersion: item.PriceVersion, - }} - } - } - return nil -} - -// PopulateChannelModelPriceTiers 将价格档附着到已经查询出的渠道模型,供路由关系图批量加载使用。 -func (r *Repository) PopulateChannelModelPriceTiers(items []model.ChannelModel) error { - pointers := make([]*model.ChannelModel, len(items)) - for index := range items { - pointers[index] = &items[index] - } - return r.attachChannelModelPriceTiers(pointers) -} - -func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) error { - if item == nil { - return nil - } - return r.attachChannelModelPriceTiers([]*model.ChannelModel{item}) -} - -func (r *Repository) DeleteChannelModel(channelID string, id string, now time.Time) error { - deleted, err := r.DeleteChannelModels(channelID, []string{id}, now) - if err != nil { - return err - } - if deleted != 1 { - return gorm.ErrRecordNotFound - } - return nil -} - -// DeleteChannelModels atomically removes a selection and refreshes the channel's -// compatibility model list. Any active route or task reference aborts the whole -// transaction so a bulk action cannot leave the administrator with a partial result. -func (r *Repository) DeleteChannelModels(channelID string, ids []string, now time.Time) (int64, error) { - ids = uniqueStrings(ids) - if len(ids) == 0 { - return 0, gorm.ErrRecordNotFound - } - var deleted int64 - err := r.db.Transaction(func(tx *gorm.DB) error { - if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { - return err - } - var existing int64 - if err := tx.Model(&model.ChannelModel{}). - Where("channel_id = ? AND id IN ?", channelID, ids). - Count(&existing).Error; err != nil { - return err - } - if existing != int64(len(ids)) { - return gorm.ErrRecordNotFound - } - var activeReferences int64 - if err := tx.Table("logical_model_routes AS route"). - Joins("JOIN logical_models AS logical_model ON logical_model.active_revision_id = route.logical_model_revision_id"). - Where("route.channel_model_id IN ?", ids). - Count(&activeReferences).Error; err != nil { - return err - } - if activeReferences > 0 { - return ErrChannelModelInUse - } - if err := tx.Model(&model.Task{}). - Where("channel_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). - Count(&activeReferences).Error; err != nil { - return err - } - if activeReferences > 0 { - return ErrChannelModelInUse - } - result := tx.Model(&model.ChannelModel{}). - Where("id IN ? AND channel_id = ?", ids, channelID). - Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != int64(len(ids)) { - return gorm.ErrRecordNotFound - } - if err := tx.Where("id IN ? AND channel_id = ?", ids, channelID).Delete(&model.ChannelModel{}).Error; err != nil { - return err - } - if err := refreshChannelModelNames(tx, channelID, now); err != nil { - return err - } - deleted = result.RowsAffected - return nil - }) - return deleted, err -} - -// SyncChannelModelNames serializes compatibility-list refreshes for a system -// channel and derives the list from the committed channel_models rows. Callers -// must not pass a previously-read snapshot because concurrent administrator -// writes could otherwise overwrite a newer catalog. -func (r *Repository) SyncChannelModelNames(channelID string, now time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { - return err - } - return refreshChannelModelNames(tx, channelID, now) - }) -} - -func (r *Repository) lockSystemChannelForModelMutation(tx *gorm.DB, channelID string) error { - query := tx.Select("id").Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem) - if r.Dialect() == "postgres" { - query = query.Clauses(clause.Locking{Strength: "UPDATE"}) - } - var channel model.ModelChannel - return query.First(&channel).Error -} - -func refreshChannelModelNames(tx *gorm.DB, channelID string, now time.Time) error { - var names []string - if err := tx.Model(&model.ChannelModel{}). - Where("channel_id = ? AND enabled = ?", channelID, true). - Order("sort_order asc, created_at asc, id asc"). - Pluck("model_key", &names).Error; err != nil { - return err - } - encoded, err := json.Marshal(names) - if err != nil { - return err - } - return tx.Model(&model.ModelChannel{}). - Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem). - Updates(map[string]any{"models_json": string(encoded), "updated_at": now}).Error -} - -func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) { - if len(items) == 0 { - return 0, nil - } - // 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。 - result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items) - return result.RowsAffected, result.Error -} - func (r *Repository) CreditAccount(userID string) (*model.CreditAccount, error) { account := model.CreditAccount{UserID: userID} if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { @@ -523,771 +196,6 @@ func enforceActiveTaskLimit(tx *gorm.DB, userID string, activeTaskLimit int) err return nil } -func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error { - return r.db.Transaction(func(tx *gorm.DB) error { - return reserveBillingOrder(tx, order) - }) -} - -func reserveBillingOrder(tx *gorm.DB, order *model.BillingOrder) error { - if err := validateBillingChargeLimit(*order, order.AmountMicrocredits); err != nil { - return err - } - if order.ReservedAmountMicrocredits <= 0 { - order.ReservedAmountMicrocredits = order.AmountMicrocredits - } - account := model.CreditAccount{UserID: order.UserID} - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { - return err - } - updated := tx.Model(&model.CreditAccount{}). - Where("user_id = ? AND available_microcredits >= ?", order.UserID, order.AmountMicrocredits). - Updates(map[string]any{ - "available_microcredits": gorm.Expr("available_microcredits - ?", order.AmountMicrocredits), - "reserved_microcredits": gorm.Expr("reserved_microcredits + ?", order.AmountMicrocredits), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return ErrInsufficientCredits - } - if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { - return err - } - if err := tx.Create(order).Error; err != nil { - return err - } - return tx.Create(&model.CreditLedgerEntry{ - ID: newRepositoryID(), - UserID: order.UserID, - Type: model.CreditLedgerReserve, - AvailableDeltaMicrocredits: -order.AmountMicrocredits, - ReservedDeltaMicrocredits: order.AmountMicrocredits, - AvailableAfterMicrocredits: account.AvailableMicrocredits, - ReservedAfterMicrocredits: account.ReservedMicrocredits, - BillingOrderID: order.ID, - Model: order.Model, - ChannelID: order.ChannelID, - Scene: order.Scene, - }).Error -} - -func (r *Repository) BillingOrder(id string) (*model.BillingOrder, error) { - var order model.BillingOrder - if err := r.db.First(&order, "id = ?", id).Error; err != nil { - return nil, err - } - return &order, nil -} - -func (r *Repository) BillingOrdersByIDs(ids []string) (map[string]model.BillingOrder, error) { - result := make(map[string]model.BillingOrder, len(ids)) - if len(ids) == 0 { - return result, nil - } - var orders []model.BillingOrder - if err := r.db.Where("id IN ?", ids).Find(&orders).Error; err != nil { - return nil, err - } - for _, order := range orders { - result[order.ID] = order - } - return result, nil -} - -func (r *Repository) BillingOrdersByTaskIDs(userID string, taskIDs []string) (map[string]model.BillingOrder, error) { - result := make(map[string]model.BillingOrder, len(taskIDs)) - if len(taskIDs) == 0 { - return result, nil - } - var orders []model.BillingOrder - if err := r.db.Where("user_id = ? AND task_id IN ?", userID, taskIDs).Find(&orders).Error; err != nil { - return nil, err - } - for _, order := range orders { - if order.TaskID != "" { - result[order.TaskID] = order - } - } - return result, nil -} - -func (r *Repository) AdminBillingOrders(status string, keyword string, limit int, offset int) ([]model.BillingOrder, int64, error) { - var items []model.BillingOrder - var total int64 - query := r.db.Model(&model.BillingOrder{}) - if status == "review" { - query = query.Joins("LEFT JOIN tasks ON tasks.id = billing_orders.task_id").Where( - "billing_orders.status = ? OR (billing_orders.status = ? AND billing_orders.updated_at < ?) OR (billing_orders.status = ? AND tasks.status IN ?)", - model.BillingStatusUncertain, model.BillingStatusRunning, time.Now().Add(-40*time.Minute), model.BillingStatusReserved, - []model.TaskStatus{model.TaskStatusFailed, model.TaskStatusCancelled}, - ) - } else if status != "" && status != "all" { - query = query.Where("billing_orders.status = ?", status) - } - if value := strings.TrimSpace(keyword); value != "" { - pattern := "%" + strings.ToLower(value) + "%" - query = query.Joins("LEFT JOIN users ON users.id = billing_orders.user_id").Where( - "lower(billing_orders.model) LIKE ? OR lower(billing_orders.scene) LIKE ? OR lower(billing_orders.provider_request_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ?", - pattern, pattern, pattern, pattern, pattern, - ) - } - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - if err := query.Select("billing_orders.*").Order("billing_orders.created_at desc").Limit(limit).Offset(offset).Find(&items).Error; err != nil { - return nil, 0, err - } - return items, total, nil -} - -func (r *Repository) TaskHasSuccessfulBillableCall(taskID string) (bool, error) { - var count int64 - err := r.db.Model(&model.ApiCallLog{}). - Where("task_id = ? AND billable = ? AND status = ?", taskID, true, model.ApiCallStatusSucceeded). - Count(&count).Error - return count > 0, err -} - -type BillingUsage struct { - InputTokens int64 - OutputTokens int64 - CachedTokens int64 -} - -func (r *Repository) BillingUsage(orderID string) (*BillingUsage, error) { - return billingUsage(r.db, orderID) -} - -func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) { - var log model.ApiCallLog - // 异步视频的真实 usage 由非计费的轮询请求返回;订单本身已经限定了归属, - // 结算应读取同订单最新的成功 usage,而不是只看创建请求。 - err := db.Where("billing_order_id = ? AND status = ? AND usage_available = ?", orderID, model.ApiCallStatusSucceeded, true). - Order("created_at desc").First(&log).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, ErrBillingUsageUnavailable - } - if err != nil { - return nil, err - } - return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil -} - -const ( - billingUsageSourceProvider = "provider" - billingUsageSourceVideoFormula = "video_formula" -) - -func tokenSettlementUsage(db *gorm.DB, order model.BillingOrder) (*BillingUsage, string, error) { - usage, err := billingUsage(db, order.ID) - if err != nil && !errors.Is(err, ErrBillingUsageUnavailable) { - return nil, "", err - } - if order.Capability != "video" { - return usage, billingUsageSourceProvider, err - } - if err == nil && usage.OutputTokens > 0 && usage.InputTokens >= 0 && usage.CachedTokens >= 0 { - return &BillingUsage{OutputTokens: usage.OutputTokens}, billingUsageSourceProvider, nil - } - // 只有提交时明确记录的公式用量可以结算,预授权 Quantity 含余量,不能用作最终用量。 - if order.VideoFormulaTokens > 0 { - return &BillingUsage{OutputTokens: order.VideoFormulaTokens}, billingUsageSourceVideoFormula, nil - } - return usage, billingUsageSourceProvider, err -} - -func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error { - return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ - "resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(), - }).Error -} - -func (r *Repository) UpdateBillingProviderRequestID(id string, providerRequestID string) error { - if id == "" || providerRequestID == "" { - return nil - } - return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ - "provider_request_id": providerRequestID, "updated_at": time.Now(), - }).Error -} - -func (r *Repository) MarkBillingRunning(id string) error { - if id == "" { - return nil - } - var order model.BillingOrder - if err := r.db.Select("id", "status").First(&order, "id = ?", id).Error; err != nil { - return err - } - if order.Status == model.BillingStatusRunning { - return nil - } - now := time.Now() - result := r.db.Model(&model.BillingOrder{}). - Where("id = ? AND status = ?", id, model.BillingStatusReserved). - Updates(map[string]any{"status": model.BillingStatusRunning, "started_at": &now, "updated_at": now}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrBillingStateConflict - } - return nil -} - -func (r *Repository) MarkBillingUncertain(id string, errorText string) error { - // uncertain 保留冻结积分,直到人工核对;这里故意不自动结算或退款。 - return r.db.Model(&model.BillingOrder{}). - Where("id = ? AND status IN ?", id, []model.BillingStatus{model.BillingStatusReserved, model.BillingStatusRunning}). - Updates(map[string]any{"status": model.BillingStatusUncertain, "error": errorText, "updated_at": time.Now()}).Error -} - -func (r *Repository) SettleBillingOrder(id string, providerRequestID string) error { - var observedUsage *BillingUsage - var observedUsageSource string - var observedActual int64 - observedActualAvailable := false - err := r.db.Transaction(func(tx *gorm.DB) error { - var order model.BillingOrder - if err := tx.First(&order, "id = ?", id).Error; err != nil { - return err - } - if order.Status == model.BillingStatusSettled { - return nil - } - if order.Status == model.BillingStatusRefunded { - return errors.New("billing order already refunded") - } - if order.BillingMode != "token" { - if err := validateBillingChargeLimit(order, order.AmountMicrocredits); err != nil { - return err - } - } - if order.BillingMode == "token" && !zeroPricedTokenOrder(order) { - usage, usageSource, err := tokenSettlementUsage(tx, order) - if err != nil { - return err - } - observedUsage = usage - observedUsageSource = usageSource - reserved := order.ReservedAmountMicrocredits - if reserved <= 0 { - reserved = order.AmountMicrocredits - } - actual, err := tokenUsageAmount(order, usage) - if err != nil { - return err - } - chargeCapped := billingChargeLimitApplies(order) && actual > order.ChargeLimitMicrocredits - if chargeCapped { - actual = order.ChargeLimitMicrocredits - } - observedActual = actual - observedActualAvailable = true - refund := max(reserved-actual, int64(0)) - supplement := max(actual-reserved, int64(0)) - updated := tx.Model(&model.CreditAccount{}). - Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, reserved). - Updates(map[string]any{ - "available_microcredits": gorm.Expr("available_microcredits + ?", refund-supplement), - "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", reserved), - "version": gorm.Expr("version + 1"), "updated_at": time.Now(), - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return errors.New("reserved credit balance is inconsistent") - } - var account model.CreditAccount - if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { - return err - } - now := time.Now() - updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now, - "actual_amount_microcredits": actual, "refunded_amount_microcredits": refund, - "input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens, - "usage_available": usageSource == billingUsageSourceProvider, "usage_source": usageSource} - if providerRequestID != "" { - updates["provider_request_id"] = providerRequestID - } - if err := tx.Model(&order).Updates(updates).Error; err != nil { - return err - } - consumeNote := "" - if chargeCapped { - consumeNote = "Token 实际用量超过 Agent 报价,已按本轮硬上限结算" - } else if supplement > 0 { - consumeNote = "Token 实际用量超过预授权,已补扣差额" - } - if usageSource == billingUsageSourceVideoFormula { - consumeNote = strings.TrimSpace("按提交时的视频 Token 公式快照结算;" + consumeNote) - } - if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume, - AmountMicrocredits: -actual, AvailableDeltaMicrocredits: -supplement, ReservedDeltaMicrocredits: -reserved, - AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, - BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: consumeNote}).Error; err != nil { - return err - } - if refund > 0 { - if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerRefund, - AmountMicrocredits: refund, AvailableDeltaMicrocredits: refund, - AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, - BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: "Token 预授权差额退回"}).Error; err != nil { - return err - } - } - return nil - } - updated := tx.Model(&model.CreditAccount{}). - Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, order.AmountMicrocredits). - Updates(map[string]any{ - "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", order.AmountMicrocredits), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return errors.New("reserved credit balance is inconsistent") - } - var account model.CreditAccount - if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { - return err - } - now := time.Now() - orderUpdates := map[string]any{"status": model.BillingStatusSettled, "actual_amount_microcredits": order.AmountMicrocredits, "settled_at": &now, "updated_at": now} - if providerRequestID != "" { - orderUpdates["provider_request_id"] = providerRequestID - } - if err := tx.Model(&order).Updates(orderUpdates).Error; err != nil { - return err - } - return tx.Create(&model.CreditLedgerEntry{ - ID: newRepositoryID(), - UserID: order.UserID, - Type: model.CreditLedgerConsume, - AmountMicrocredits: -order.AmountMicrocredits, - ReservedDeltaMicrocredits: -order.AmountMicrocredits, - AvailableAfterMicrocredits: account.AvailableMicrocredits, - ReservedAfterMicrocredits: account.ReservedMicrocredits, - BillingOrderID: order.ID, - Model: order.Model, - ChannelID: order.ChannelID, - Scene: order.Scene, - }).Error - }) - if err != nil && observedUsage != nil { - // 即使账户状态异常导致回滚,也保留结算依据并标明来源,供用户和管理员核对。 - updates := map[string]any{ - "input_tokens": observedUsage.InputTokens, "output_tokens": observedUsage.OutputTokens, - "cached_tokens": observedUsage.CachedTokens, "usage_available": observedUsageSource == billingUsageSourceProvider, - "usage_source": observedUsageSource, "updated_at": time.Now(), - } - if observedActualAvailable { - updates["actual_amount_microcredits"] = observedActual - } - if providerRequestID != "" { - updates["provider_request_id"] = providerRequestID - } - usageErr := r.db.Model(&model.BillingOrder{}). - Where("id = ? AND status NOT IN ?", id, []model.BillingStatus{model.BillingStatusSettled, model.BillingStatusRefunded}). - Updates(updates).Error - if usageErr != nil { - return errors.Join(err, usageErr) - } - } - return err -} - -// RestoreRefundedBillingOrder compensates a billing order that was refunded -// before an operator confirmed the upstream task had actually succeeded. -// -// The original reservation has already been returned to available credits, so -// this transition charges the final amount directly from available credits. -// The conditional refunded -> settled update keeps concurrent/repeated manual -// recovery requests idempotent. -func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var order model.BillingOrder - if err := tx.First(&order, "id = ?", id).Error; err != nil { - return err - } - if order.Status == model.BillingStatusSettled { - return nil - } - if order.Status != model.BillingStatusRefunded { - return ErrBillingStateConflict - } - if order.BillingMode != "token" { - if err := validateBillingChargeLimit(order, order.AmountMicrocredits); err != nil { - return err - } - } - - actual := order.AmountMicrocredits - var usage *BillingUsage - var usageSource string - if order.BillingMode == "token" { - if zeroPricedTokenOrder(order) { - actual = 0 - } else { - var err error - usage, usageSource, err = tokenSettlementUsage(tx, order) - if err != nil { - return err - } - actual, err = tokenUsageAmount(order, usage) - if err != nil { - return err - } - } - } - chargeCapped := billingChargeLimitApplies(order) && actual > order.ChargeLimitMicrocredits - if chargeCapped { - actual = order.ChargeLimitMicrocredits - } - if actual < 0 { - return errors.New("invalid restored billing amount") - } - - updated := tx.Model(&model.CreditAccount{}). - Where("user_id = ?", order.UserID). - Updates(map[string]any{ - "available_microcredits": gorm.Expr("available_microcredits - ?", actual), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return errors.New("credit account does not exist") - } - - var account model.CreditAccount - if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { - return err - } - now := time.Now() - orderUpdates := map[string]any{ - "status": model.BillingStatusSettled, - "actual_amount_microcredits": actual, - "refunded_amount_microcredits": max(order.AmountMicrocredits-actual, int64(0)), - "refunded_at": nil, - "settled_at": &now, - "error": "", - "updated_at": now, - } - if providerRequestID != "" { - orderUpdates["provider_request_id"] = providerRequestID - } - if usage != nil { - orderUpdates["input_tokens"] = usage.InputTokens - orderUpdates["output_tokens"] = usage.OutputTokens - orderUpdates["cached_tokens"] = usage.CachedTokens - orderUpdates["usage_available"] = usageSource == billingUsageSourceProvider - orderUpdates["usage_source"] = usageSource - } - orderUpdate := tx.Model(&model.BillingOrder{}). - Where("id = ? AND status = ?", order.ID, model.BillingStatusRefunded). - Updates(orderUpdates) - if orderUpdate.Error != nil { - return orderUpdate.Error - } - if orderUpdate.RowsAffected != 1 { - return ErrBillingStateConflict - } - - consumeNote := "人工查询确认上游成功,退款订单重新扣费" - if usageSource == billingUsageSourceVideoFormula { - consumeNote += ";按提交时的视频 Token 公式快照结算" - } - if chargeCapped { - consumeNote += ";已按本轮 Agent 硬上限结算" - } - return tx.Create(&model.CreditLedgerEntry{ - ID: newRepositoryID(), - UserID: order.UserID, - Type: model.CreditLedgerConsume, - AmountMicrocredits: -actual, - AvailableDeltaMicrocredits: -actual, - AvailableAfterMicrocredits: account.AvailableMicrocredits, - ReservedAfterMicrocredits: account.ReservedMicrocredits, - BillingOrderID: order.ID, - Model: order.Model, - ChannelID: order.ChannelID, - Scene: order.Scene, - Note: consumeNote, - }).Error - }) -} - -func zeroPricedTokenOrder(order model.BillingOrder) bool { - return order.BillingMode == "token" && - order.InputTokenPriceMicrocredits == 0 && - order.OutputTokenPriceMicrocredits == 0 && - order.CachedTokenPriceMicrocredits == 0 -} - -func (r *Repository) RefundBillingOrder(id string, errorText string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var order model.BillingOrder - if err := tx.First(&order, "id = ?", id).Error; err != nil { - return err - } - if order.Status == model.BillingStatusRefunded { - return nil - } - if order.Status == model.BillingStatusSettled { - return errors.New("settled billing order requires a manual refund") - } - updated := tx.Model(&model.CreditAccount{}). - Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, order.AmountMicrocredits). - Updates(map[string]any{ - "available_microcredits": gorm.Expr("available_microcredits + ?", order.AmountMicrocredits), - "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", order.AmountMicrocredits), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return errors.New("reserved credit balance is inconsistent") - } - var account model.CreditAccount - if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { - return err - } - now := time.Now() - updates := map[string]any{"status": model.BillingStatusRefunded, "error": errorText, "refunded_amount_microcredits": order.AmountMicrocredits, "refunded_at": &now, "updated_at": now} - if err := tx.Model(&order).Updates(updates).Error; err != nil { - return err - } - return tx.Create(&model.CreditLedgerEntry{ - ID: newRepositoryID(), - UserID: order.UserID, - Type: model.CreditLedgerRefund, - AmountMicrocredits: order.AmountMicrocredits, - AvailableDeltaMicrocredits: order.AmountMicrocredits, - ReservedDeltaMicrocredits: -order.AmountMicrocredits, - AvailableAfterMicrocredits: account.AvailableMicrocredits, - ReservedAfterMicrocredits: account.ReservedMicrocredits, - BillingOrderID: order.ID, - Model: order.Model, - ChannelID: order.ChannelID, - Scene: order.Scene, - Note: errorText, - }).Error - }) -} - -func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, error) { - if usage == nil { - return 0, ErrBillingUsageUnavailable - } - if usage.InputTokens < 0 || usage.OutputTokens < 0 || usage.CachedTokens < 0 { - return 0, errors.New("invalid token usage amount") - } - if order.Capability == "video" && usage.OutputTokens <= 0 { - return 0, ErrBillingUsageUnavailable - } - terms := []kernel.TokenBillingTerm{ - {Tokens: usage.OutputTokens, PriceMicrocredits: order.OutputTokenPriceMicrocredits}, - } - // 视频 Token 总用量已由供应商 completion_tokens 表达,不再叠加文本输入和缓存费用。 - if order.Capability != "video" { - input := max(usage.InputTokens-usage.CachedTokens, 0) - terms = append(terms, - kernel.TokenBillingTerm{Tokens: input, PriceMicrocredits: order.InputTokenPriceMicrocredits}, - kernel.TokenBillingTerm{Tokens: usage.CachedTokens, PriceMicrocredits: order.CachedTokenPriceMicrocredits}, - ) - } - amount, err := kernel.TokenBillingAmount(order.MultiplierBasisPoints, terms...) - if err != nil { - return 0, errors.New("invalid token usage amount") - } - return amount, nil -} - -func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) { - var account model.CreditAccount - err := r.db.Transaction(func(tx *gorm.DB) error { - account = model.CreditAccount{UserID: userID} - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { - return err - } - accountQuery := tx.Model(&model.CreditAccount{}).Where("user_id = ?", userID) - if amount < 0 { - accountQuery = accountQuery.Where("available_microcredits + ? >= 0", amount) - } - updated := accountQuery. - Updates(map[string]any{ - "available_microcredits": gorm.Expr("available_microcredits + ?", amount), - "version": gorm.Expr("version + 1"), - "updated_at": time.Now(), - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return ErrInsufficientCredits - } - if err := tx.First(&account, "user_id = ?", userID).Error; err != nil { - return err - } - entryType := model.CreditLedgerAdminAdjust - if amount > 0 { - entryType = model.CreditLedgerAdminGrant - } - return tx.Create(&model.CreditLedgerEntry{ - ID: newRepositoryID(), - UserID: userID, - Type: entryType, - AmountMicrocredits: amount, - AvailableDeltaMicrocredits: amount, - AvailableAfterMicrocredits: account.AvailableMicrocredits, - ReservedAfterMicrocredits: account.ReservedMicrocredits, - ActorUserID: actorUserID, - Note: note, - }).Error - }) - return &account, err -} - -func (r *Repository) CreateRedeemBatch(batch *model.RedeemBatch, codes []model.RedeemCode) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(batch).Error; err != nil { - return err - } - return tx.CreateInBatches(&codes, 200).Error - }) -} - -func (r *Repository) AdminRedeemBatches(keyword string, validity string, limit int, offset int) ([]model.RedeemBatch, int64, error) { - var items []model.RedeemBatch - var total int64 - query := r.db.Model(&model.RedeemBatch{}) - if value := strings.TrimSpace(keyword); value != "" { - pattern := "%" + strings.ToLower(value) + "%" - query = query.Where("lower(note) LIKE ? OR CAST(amount_microcredits AS TEXT) LIKE ? OR CAST(count AS TEXT) LIKE ?", pattern, pattern, pattern) - } - if validity == "active" { - query = query.Where("expires_at IS NULL OR expires_at > ?", time.Now()) - } else if validity == "expired" { - query = query.Where("expires_at IS NOT NULL AND expires_at <= ?", time.Now()) - } - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - now := time.Now() - listQuery := query.Select(`redeem_batches.id, redeem_batches.amount_microcredits, redeem_batches.count, - redeem_batches.note, redeem_batches.created_by, redeem_batches.expires_at, redeem_batches.created_at, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND (rc.expires_at IS NULL OR rc.expires_at > ?)) AS available_count, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'redeemed') AS redeemed_count, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'disabled') AS disabled_count, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND rc.expires_at IS NOT NULL AND rc.expires_at <= ?) AS expired_count`, now, now) - if err := listQuery.Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error; err != nil { - return nil, 0, err - } - return items, total, nil -} - -func (r *Repository) RedeemBatch(id string) (*model.RedeemBatch, error) { - var batch model.RedeemBatch - now := time.Now() - query := r.db.Model(&model.RedeemBatch{}).Select(`redeem_batches.*, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND (rc.expires_at IS NULL OR rc.expires_at > ?)) AS available_count, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'redeemed') AS redeemed_count, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'disabled') AS disabled_count, - (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND rc.expires_at IS NOT NULL AND rc.expires_at <= ?) AS expired_count`, now, now) - if err := query.First(&batch, "redeem_batches.id = ?", id).Error; err != nil { - return nil, err - } - return &batch, nil -} - -func (r *Repository) AdminRedeemCodes(batchID string, status string, limit int, offset int) ([]AdminRedeemCodeRow, int64, error) { - var items []AdminRedeemCodeRow - var total int64 - query := r.db.Model(&model.RedeemCode{}).Where("redeem_codes.batch_id = ?", batchID) - now := time.Now() - switch status { - case "available": - query = query.Where("redeem_codes.status = ? AND (redeem_codes.expires_at IS NULL OR redeem_codes.expires_at > ?)", model.RedeemCodeUnused, now) - case "redeemed": - query = query.Where("redeem_codes.status = ?", model.RedeemCodeRedeemed) - case "disabled": - query = query.Where("redeem_codes.status = ?", model.RedeemCodeDisabled) - case "expired": - query = query.Where("redeem_codes.status = ? AND redeem_codes.expires_at IS NOT NULL AND redeem_codes.expires_at <= ?", model.RedeemCodeUnused, now) - } - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - err := query.Select("redeem_codes.*, users.username AS redeemed_username, users.display_name AS redeemed_display_name"). - Joins("LEFT JOIN users ON users.id = redeem_codes.redeemed_by"). - Order("redeem_codes.created_at asc, redeem_codes.id asc").Limit(limit).Offset(offset).Scan(&items).Error - return items, total, err -} - -func (r *Repository) RedeemCode(userID string, codeHash string, redeemedIP string) (*model.CreditAccount, error) { - var account model.CreditAccount - err := r.db.Transaction(func(tx *gorm.DB) error { - var code model.RedeemCode - if err := tx.First(&code, "code_hash = ?", codeHash).Error; err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return ErrRedeemCodeInvalid - } - return err - } - now := time.Now() - query := tx.Model(&model.RedeemCode{}).Where("id = ? AND status = ?", code.ID, model.RedeemCodeUnused) - if code.ExpiresAt != nil { - query = query.Where("expires_at > ?", now) - } - updated := query.Updates(map[string]any{"status": model.RedeemCodeRedeemed, "redeemed_by": userID, "redeemed_at": &now, "redeemed_ip": redeemedIP, "updated_at": now}) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return ErrRedeemCodeInvalid - } - account = model.CreditAccount{UserID: userID} - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { - return err - } - if err := tx.Model(&model.CreditAccount{}).Where("user_id = ?", userID).Updates(map[string]any{ - "available_microcredits": gorm.Expr("available_microcredits + ?", code.AmountMicrocredits), - "version": gorm.Expr("version + 1"), - "updated_at": now, - }).Error; err != nil { - return err - } - if err := tx.First(&account, "user_id = ?", userID).Error; err != nil { - return err - } - return tx.Create(&model.CreditLedgerEntry{ - ID: newRepositoryID(), - UserID: userID, - Type: model.CreditLedgerRedeem, - AmountMicrocredits: code.AmountMicrocredits, - AvailableDeltaMicrocredits: code.AmountMicrocredits, - AvailableAfterMicrocredits: account.AvailableMicrocredits, - ReservedAfterMicrocredits: account.ReservedMicrocredits, - RedeemCodeID: code.ID, - Note: "兑换码充值", - }).Error - }) - return &account, err -} - func newRepositoryID() string { return randomRepositorySuffix() } diff --git a/backend/internal/repository/finance_billing.go b/backend/internal/repository/finance_billing.go new file mode 100644 index 000000000..9eb60971d --- /dev/null +++ b/backend/internal/repository/finance_billing.go @@ -0,0 +1,610 @@ +// 计费订单:预留、运行中、结算、退款与恢复。 +// +// 积分扣减遵循「先预留、再按实际用量结算、多退少补」;所有状态迁移都带期望状态条件, +// 并发或重复的结算/退款请求只有一个会生效(ErrBillingStateConflict)。 + +package repository + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) ReserveBillingOrder(order *model.BillingOrder) error { + return r.db.Transaction(func(tx *gorm.DB) error { + return reserveBillingOrder(tx, order) + }) +} + +func reserveBillingOrder(tx *gorm.DB, order *model.BillingOrder) error { + if err := validateBillingChargeLimit(*order, order.AmountMicrocredits); err != nil { + return err + } + if order.ReservedAmountMicrocredits <= 0 { + order.ReservedAmountMicrocredits = order.AmountMicrocredits + } + account := model.CreditAccount{UserID: order.UserID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { + return err + } + updated := tx.Model(&model.CreditAccount{}). + Where("user_id = ? AND available_microcredits >= ?", order.UserID, order.AmountMicrocredits). + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits - ?", order.AmountMicrocredits), + "reserved_microcredits": gorm.Expr("reserved_microcredits + ?", order.AmountMicrocredits), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrInsufficientCredits + } + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + if err := tx.Create(order).Error; err != nil { + return err + } + return tx.Create(&model.CreditLedgerEntry{ + ID: newRepositoryID(), + UserID: order.UserID, + Type: model.CreditLedgerReserve, + AvailableDeltaMicrocredits: -order.AmountMicrocredits, + ReservedDeltaMicrocredits: order.AmountMicrocredits, + AvailableAfterMicrocredits: account.AvailableMicrocredits, + ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, + Model: order.Model, + ChannelID: order.ChannelID, + Scene: order.Scene, + }).Error +} + +func (r *Repository) BillingOrder(id string) (*model.BillingOrder, error) { + var order model.BillingOrder + if err := r.db.First(&order, "id = ?", id).Error; err != nil { + return nil, err + } + return &order, nil +} + +func (r *Repository) BillingOrdersByIDs(ids []string) (map[string]model.BillingOrder, error) { + result := make(map[string]model.BillingOrder, len(ids)) + if len(ids) == 0 { + return result, nil + } + var orders []model.BillingOrder + if err := r.db.Where("id IN ?", ids).Find(&orders).Error; err != nil { + return nil, err + } + for _, order := range orders { + result[order.ID] = order + } + return result, nil +} + +func (r *Repository) BillingOrdersByTaskIDs(userID string, taskIDs []string) (map[string]model.BillingOrder, error) { + result := make(map[string]model.BillingOrder, len(taskIDs)) + if len(taskIDs) == 0 { + return result, nil + } + var orders []model.BillingOrder + if err := r.db.Where("user_id = ? AND task_id IN ?", userID, taskIDs).Find(&orders).Error; err != nil { + return nil, err + } + for _, order := range orders { + if order.TaskID != "" { + result[order.TaskID] = order + } + } + return result, nil +} + +func (r *Repository) AdminBillingOrders(status string, keyword string, limit int, offset int) ([]model.BillingOrder, int64, error) { + var items []model.BillingOrder + var total int64 + query := r.db.Model(&model.BillingOrder{}) + if status == "review" { + query = query.Joins("LEFT JOIN tasks ON tasks.id = billing_orders.task_id").Where( + "billing_orders.status = ? OR (billing_orders.status = ? AND billing_orders.updated_at < ?) OR (billing_orders.status = ? AND tasks.status IN ?)", + model.BillingStatusUncertain, model.BillingStatusRunning, time.Now().Add(-40*time.Minute), model.BillingStatusReserved, + []model.TaskStatus{model.TaskStatusFailed, model.TaskStatusCancelled}, + ) + } else if status != "" && status != "all" { + query = query.Where("billing_orders.status = ?", status) + } + if value := strings.TrimSpace(keyword); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Joins("LEFT JOIN users ON users.id = billing_orders.user_id").Where( + "lower(billing_orders.model) LIKE ? OR lower(billing_orders.scene) LIKE ? OR lower(billing_orders.provider_request_id) LIKE ? OR lower(users.username) LIKE ? OR lower(users.display_name) LIKE ?", + pattern, pattern, pattern, pattern, pattern, + ) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Select("billing_orders.*").Order("billing_orders.created_at desc").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *Repository) TaskHasSuccessfulBillableCall(taskID string) (bool, error) { + var count int64 + err := r.db.Model(&model.ApiCallLog{}). + Where("task_id = ? AND billable = ? AND status = ?", taskID, true, model.ApiCallStatusSucceeded). + Count(&count).Error + return count > 0, err +} + +type BillingUsage struct { + InputTokens int64 + OutputTokens int64 + CachedTokens int64 +} + +func (r *Repository) BillingUsage(orderID string) (*BillingUsage, error) { + return billingUsage(r.db, orderID) +} + +func billingUsage(db *gorm.DB, orderID string) (*BillingUsage, error) { + var log model.ApiCallLog + // 异步视频的真实 usage 由非计费的轮询请求返回;订单本身已经限定了归属, + // 结算应读取同订单最新的成功 usage,而不是只看创建请求。 + err := db.Where("billing_order_id = ? AND status = ? AND usage_available = ?", orderID, model.ApiCallStatusSucceeded, true). + Order("created_at desc").First(&log).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrBillingUsageUnavailable + } + if err != nil { + return nil, err + } + return &BillingUsage{InputTokens: log.InputTokens, OutputTokens: log.OutputTokens, CachedTokens: log.CachedTokens}, nil +} + +const ( + billingUsageSourceProvider = "provider" + billingUsageSourceVideoFormula = "video_formula" +) + +func tokenSettlementUsage(db *gorm.DB, order model.BillingOrder) (*BillingUsage, string, error) { + usage, err := billingUsage(db, order.ID) + if err != nil && !errors.Is(err, ErrBillingUsageUnavailable) { + return nil, "", err + } + if order.Capability != "video" { + return usage, billingUsageSourceProvider, err + } + if err == nil && usage.OutputTokens > 0 && usage.InputTokens >= 0 && usage.CachedTokens >= 0 { + return &BillingUsage{OutputTokens: usage.OutputTokens}, billingUsageSourceProvider, nil + } + // 只有提交时明确记录的公式用量可以结算,预授权 Quantity 含余量,不能用作最终用量。 + if order.VideoFormulaTokens > 0 { + return &BillingUsage{OutputTokens: order.VideoFormulaTokens}, billingUsageSourceVideoFormula, nil + } + return usage, billingUsageSourceProvider, err +} + +func (r *Repository) RecordBillingResolution(id string, actorUserID string, note string) error { + return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ + "resolved_by": actorUserID, "resolution_note": note, "updated_at": time.Now(), + }).Error +} + +func (r *Repository) UpdateBillingProviderRequestID(id string, providerRequestID string) error { + if id == "" || providerRequestID == "" { + return nil + } + return r.db.Model(&model.BillingOrder{}).Where("id = ?", id).Updates(map[string]any{ + "provider_request_id": providerRequestID, "updated_at": time.Now(), + }).Error +} + +func (r *Repository) MarkBillingRunning(id string) error { + if id == "" { + return nil + } + var order model.BillingOrder + if err := r.db.Select("id", "status").First(&order, "id = ?", id).Error; err != nil { + return err + } + if order.Status == model.BillingStatusRunning { + return nil + } + now := time.Now() + result := r.db.Model(&model.BillingOrder{}). + Where("id = ? AND status = ?", id, model.BillingStatusReserved). + Updates(map[string]any{"status": model.BillingStatusRunning, "started_at": &now, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrBillingStateConflict + } + return nil +} + +func (r *Repository) MarkBillingUncertain(id string, errorText string) error { + // uncertain 保留冻结积分,直到人工核对;这里故意不自动结算或退款。 + return r.db.Model(&model.BillingOrder{}). + Where("id = ? AND status IN ?", id, []model.BillingStatus{model.BillingStatusReserved, model.BillingStatusRunning}). + Updates(map[string]any{"status": model.BillingStatusUncertain, "error": errorText, "updated_at": time.Now()}).Error +} + +func (r *Repository) SettleBillingOrder(id string, providerRequestID string) error { + var observedUsage *BillingUsage + var observedUsageSource string + var observedActual int64 + observedActualAvailable := false + err := r.db.Transaction(func(tx *gorm.DB) error { + var order model.BillingOrder + if err := tx.First(&order, "id = ?", id).Error; err != nil { + return err + } + if order.Status == model.BillingStatusSettled { + return nil + } + if order.Status == model.BillingStatusRefunded { + return errors.New("billing order already refunded") + } + if order.BillingMode != "token" { + if err := validateBillingChargeLimit(order, order.AmountMicrocredits); err != nil { + return err + } + } + if order.BillingMode == "token" && !zeroPricedTokenOrder(order) { + usage, usageSource, err := tokenSettlementUsage(tx, order) + if err != nil { + return err + } + observedUsage = usage + observedUsageSource = usageSource + reserved := order.ReservedAmountMicrocredits + if reserved <= 0 { + reserved = order.AmountMicrocredits + } + actual, err := tokenUsageAmount(order, usage) + if err != nil { + return err + } + chargeCapped := billingChargeLimitApplies(order) && actual > order.ChargeLimitMicrocredits + if chargeCapped { + actual = order.ChargeLimitMicrocredits + } + observedActual = actual + observedActualAvailable = true + refund := max(reserved-actual, int64(0)) + supplement := max(actual-reserved, int64(0)) + updated := tx.Model(&model.CreditAccount{}). + Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, reserved). + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits + ?", refund-supplement), + "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", reserved), + "version": gorm.Expr("version + 1"), "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return errors.New("reserved credit balance is inconsistent") + } + var account model.CreditAccount + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + now := time.Now() + updates := map[string]any{"status": model.BillingStatusSettled, "settled_at": &now, "updated_at": now, + "actual_amount_microcredits": actual, "refunded_amount_microcredits": refund, + "input_tokens": usage.InputTokens, "output_tokens": usage.OutputTokens, "cached_tokens": usage.CachedTokens, + "usage_available": usageSource == billingUsageSourceProvider, "usage_source": usageSource} + if providerRequestID != "" { + updates["provider_request_id"] = providerRequestID + } + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + consumeNote := "" + if chargeCapped { + consumeNote = "Token 实际用量超过 Agent 报价,已按本轮硬上限结算" + } else if supplement > 0 { + consumeNote = "Token 实际用量超过预授权,已补扣差额" + } + if usageSource == billingUsageSourceVideoFormula { + consumeNote = strings.TrimSpace("按提交时的视频 Token 公式快照结算;" + consumeNote) + } + if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerConsume, + AmountMicrocredits: -actual, AvailableDeltaMicrocredits: -supplement, ReservedDeltaMicrocredits: -reserved, + AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: consumeNote}).Error; err != nil { + return err + } + if refund > 0 { + if err := tx.Create(&model.CreditLedgerEntry{ID: newRepositoryID(), UserID: order.UserID, Type: model.CreditLedgerRefund, + AmountMicrocredits: refund, AvailableDeltaMicrocredits: refund, + AvailableAfterMicrocredits: account.AvailableMicrocredits, ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, Model: order.Model, ChannelID: order.ChannelID, Scene: order.Scene, Note: "Token 预授权差额退回"}).Error; err != nil { + return err + } + } + return nil + } + updated := tx.Model(&model.CreditAccount{}). + Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, order.AmountMicrocredits). + Updates(map[string]any{ + "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", order.AmountMicrocredits), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return errors.New("reserved credit balance is inconsistent") + } + var account model.CreditAccount + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + now := time.Now() + orderUpdates := map[string]any{"status": model.BillingStatusSettled, "actual_amount_microcredits": order.AmountMicrocredits, "settled_at": &now, "updated_at": now} + if providerRequestID != "" { + orderUpdates["provider_request_id"] = providerRequestID + } + if err := tx.Model(&order).Updates(orderUpdates).Error; err != nil { + return err + } + return tx.Create(&model.CreditLedgerEntry{ + ID: newRepositoryID(), + UserID: order.UserID, + Type: model.CreditLedgerConsume, + AmountMicrocredits: -order.AmountMicrocredits, + ReservedDeltaMicrocredits: -order.AmountMicrocredits, + AvailableAfterMicrocredits: account.AvailableMicrocredits, + ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, + Model: order.Model, + ChannelID: order.ChannelID, + Scene: order.Scene, + }).Error + }) + if err != nil && observedUsage != nil { + // 即使账户状态异常导致回滚,也保留结算依据并标明来源,供用户和管理员核对。 + updates := map[string]any{ + "input_tokens": observedUsage.InputTokens, "output_tokens": observedUsage.OutputTokens, + "cached_tokens": observedUsage.CachedTokens, "usage_available": observedUsageSource == billingUsageSourceProvider, + "usage_source": observedUsageSource, "updated_at": time.Now(), + } + if observedActualAvailable { + updates["actual_amount_microcredits"] = observedActual + } + if providerRequestID != "" { + updates["provider_request_id"] = providerRequestID + } + usageErr := r.db.Model(&model.BillingOrder{}). + Where("id = ? AND status NOT IN ?", id, []model.BillingStatus{model.BillingStatusSettled, model.BillingStatusRefunded}). + Updates(updates).Error + if usageErr != nil { + return errors.Join(err, usageErr) + } + } + return err +} + +// RestoreRefundedBillingOrder compensates a billing order that was refunded +// before an operator confirmed the upstream task had actually succeeded. +// +// The original reservation has already been returned to available credits, so +// this transition charges the final amount directly from available credits. +// The conditional refunded -> settled update keeps concurrent/repeated manual +// recovery requests idempotent. +func (r *Repository) RestoreRefundedBillingOrder(id string, providerRequestID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var order model.BillingOrder + if err := tx.First(&order, "id = ?", id).Error; err != nil { + return err + } + if order.Status == model.BillingStatusSettled { + return nil + } + if order.Status != model.BillingStatusRefunded { + return ErrBillingStateConflict + } + if order.BillingMode != "token" { + if err := validateBillingChargeLimit(order, order.AmountMicrocredits); err != nil { + return err + } + } + + actual := order.AmountMicrocredits + var usage *BillingUsage + var usageSource string + if order.BillingMode == "token" { + if zeroPricedTokenOrder(order) { + actual = 0 + } else { + var err error + usage, usageSource, err = tokenSettlementUsage(tx, order) + if err != nil { + return err + } + actual, err = tokenUsageAmount(order, usage) + if err != nil { + return err + } + } + } + chargeCapped := billingChargeLimitApplies(order) && actual > order.ChargeLimitMicrocredits + if chargeCapped { + actual = order.ChargeLimitMicrocredits + } + if actual < 0 { + return errors.New("invalid restored billing amount") + } + + updated := tx.Model(&model.CreditAccount{}). + Where("user_id = ?", order.UserID). + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits - ?", actual), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return errors.New("credit account does not exist") + } + + var account model.CreditAccount + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + now := time.Now() + orderUpdates := map[string]any{ + "status": model.BillingStatusSettled, + "actual_amount_microcredits": actual, + "refunded_amount_microcredits": max(order.AmountMicrocredits-actual, int64(0)), + "refunded_at": nil, + "settled_at": &now, + "error": "", + "updated_at": now, + } + if providerRequestID != "" { + orderUpdates["provider_request_id"] = providerRequestID + } + if usage != nil { + orderUpdates["input_tokens"] = usage.InputTokens + orderUpdates["output_tokens"] = usage.OutputTokens + orderUpdates["cached_tokens"] = usage.CachedTokens + orderUpdates["usage_available"] = usageSource == billingUsageSourceProvider + orderUpdates["usage_source"] = usageSource + } + orderUpdate := tx.Model(&model.BillingOrder{}). + Where("id = ? AND status = ?", order.ID, model.BillingStatusRefunded). + Updates(orderUpdates) + if orderUpdate.Error != nil { + return orderUpdate.Error + } + if orderUpdate.RowsAffected != 1 { + return ErrBillingStateConflict + } + + consumeNote := "人工查询确认上游成功,退款订单重新扣费" + if usageSource == billingUsageSourceVideoFormula { + consumeNote += ";按提交时的视频 Token 公式快照结算" + } + if chargeCapped { + consumeNote += ";已按本轮 Agent 硬上限结算" + } + return tx.Create(&model.CreditLedgerEntry{ + ID: newRepositoryID(), + UserID: order.UserID, + Type: model.CreditLedgerConsume, + AmountMicrocredits: -actual, + AvailableDeltaMicrocredits: -actual, + AvailableAfterMicrocredits: account.AvailableMicrocredits, + ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, + Model: order.Model, + ChannelID: order.ChannelID, + Scene: order.Scene, + Note: consumeNote, + }).Error + }) +} + +func zeroPricedTokenOrder(order model.BillingOrder) bool { + return order.BillingMode == "token" && + order.InputTokenPriceMicrocredits == 0 && + order.OutputTokenPriceMicrocredits == 0 && + order.CachedTokenPriceMicrocredits == 0 +} + +func (r *Repository) RefundBillingOrder(id string, errorText string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var order model.BillingOrder + if err := tx.First(&order, "id = ?", id).Error; err != nil { + return err + } + if order.Status == model.BillingStatusRefunded { + return nil + } + if order.Status == model.BillingStatusSettled { + return errors.New("settled billing order requires a manual refund") + } + updated := tx.Model(&model.CreditAccount{}). + Where("user_id = ? AND reserved_microcredits >= ?", order.UserID, order.AmountMicrocredits). + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits + ?", order.AmountMicrocredits), + "reserved_microcredits": gorm.Expr("reserved_microcredits - ?", order.AmountMicrocredits), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return errors.New("reserved credit balance is inconsistent") + } + var account model.CreditAccount + if err := tx.First(&account, "user_id = ?", order.UserID).Error; err != nil { + return err + } + now := time.Now() + updates := map[string]any{"status": model.BillingStatusRefunded, "error": errorText, "refunded_amount_microcredits": order.AmountMicrocredits, "refunded_at": &now, "updated_at": now} + if err := tx.Model(&order).Updates(updates).Error; err != nil { + return err + } + return tx.Create(&model.CreditLedgerEntry{ + ID: newRepositoryID(), + UserID: order.UserID, + Type: model.CreditLedgerRefund, + AmountMicrocredits: order.AmountMicrocredits, + AvailableDeltaMicrocredits: order.AmountMicrocredits, + ReservedDeltaMicrocredits: -order.AmountMicrocredits, + AvailableAfterMicrocredits: account.AvailableMicrocredits, + ReservedAfterMicrocredits: account.ReservedMicrocredits, + BillingOrderID: order.ID, + Model: order.Model, + ChannelID: order.ChannelID, + Scene: order.Scene, + Note: errorText, + }).Error + }) +} + +func tokenUsageAmount(order model.BillingOrder, usage *BillingUsage) (int64, error) { + if usage == nil { + return 0, ErrBillingUsageUnavailable + } + if usage.InputTokens < 0 || usage.OutputTokens < 0 || usage.CachedTokens < 0 { + return 0, errors.New("invalid token usage amount") + } + if order.Capability == "video" && usage.OutputTokens <= 0 { + return 0, ErrBillingUsageUnavailable + } + terms := []kernel.TokenBillingTerm{ + {Tokens: usage.OutputTokens, PriceMicrocredits: order.OutputTokenPriceMicrocredits}, + } + // 视频 Token 总用量已由供应商 completion_tokens 表达,不再叠加文本输入和缓存费用。 + if order.Capability != "video" { + input := max(usage.InputTokens-usage.CachedTokens, 0) + terms = append(terms, + kernel.TokenBillingTerm{Tokens: input, PriceMicrocredits: order.InputTokenPriceMicrocredits}, + kernel.TokenBillingTerm{Tokens: usage.CachedTokens, PriceMicrocredits: order.CachedTokenPriceMicrocredits}, + ) + } + amount, err := kernel.TokenBillingAmount(order.MultiplierBasisPoints, terms...) + if err != nil { + return 0, errors.New("invalid token usage amount") + } + return amount, nil +} diff --git a/backend/internal/repository/finance_channel_models.go b/backend/internal/repository/finance_channel_models.go new file mode 100644 index 000000000..60621ba03 --- /dev/null +++ b/backend/internal/repository/finance_channel_models.go @@ -0,0 +1,339 @@ +// 渠道模型与价格档的持久化。 +// +// 修改渠道下的模型前先锁定渠道行(lockSystemChannelForModelMutation),避免并发编辑 +// 产生重复价格档或遗漏名称同步。 + +package repository + +import ( + "encoding/json" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +type AdminRedeemCodeRow struct { + model.RedeemCode + RedeemedUsername string `json:"redeemedUsername" gorm:"column:redeemed_username"` + RedeemedDisplayName string `json:"redeemedDisplayName" gorm:"column:redeemed_display_name"` +} + +func (r *Repository) ChannelModels(channelID string, includeDisabled bool) ([]model.ChannelModel, error) { + var items []model.ChannelModel + query := r.db.Where("channel_id = ?", channelID).Order("sort_order asc, created_at asc, id asc") + if !includeDisabled { + query = query.Where("enabled = ?", true) + } + if err := query.Find(&items).Error; err != nil { + return nil, err + } + pointers := make([]*model.ChannelModel, len(items)) + for index := range items { + pointers[index] = &items[index] + } + return items, r.attachChannelModelPriceTiers(pointers) +} + +func (r *Repository) ChannelModelReferences(channelIDs []string) ([]model.ChannelModel, error) { + if len(channelIDs) == 0 { + return []model.ChannelModel{}, nil + } + var items []model.ChannelModel + if err := r.db.Select("id", "channel_id", "model_key", "display_name", "sort_order", "enabled"). + Where("channel_id IN ?", channelIDs). + Order("channel_id asc, sort_order asc, created_at asc, id asc"). + Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (r *Repository) CreateDuplicatedSystemChannel(channel *model.ModelChannel, channelModels []model.ChannelModel, priceTiers []model.ChannelModelPriceTier) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(channel).Error; err != nil { + return err + } + if len(channelModels) > 0 { + if err := tx.Create(&channelModels).Error; err != nil { + return err + } + } + if len(priceTiers) > 0 { + if err := tx.Create(&priceTiers).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *Repository) ChannelModelByID(channelID string, id string) (*model.ChannelModel, error) { + var item model.ChannelModel + if err := r.db.First(&item, "id = ? AND channel_id = ?", id, channelID).Error; err != nil { + return nil, err + } + if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { + return nil, err + } + return &item, nil +} + +func (r *Repository) ChannelModelByKey(channelID string, modelKey string) (*model.ChannelModel, error) { + var item model.ChannelModel + if err := r.db.First(&item, "channel_id = ? AND model_key = ? AND enabled = ?", channelID, modelKey, true).Error; err != nil { + return nil, err + } + if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { + return nil, err + } + return &item, nil +} + +func (r *Repository) ChannelModelByKeyIncludingDisabled(channelID string, modelKey string) (*model.ChannelModel, error) { + var item model.ChannelModel + if err := r.db.First(&item, "channel_id = ? AND model_key = ?", channelID, modelKey).Error; err != nil { + return nil, err + } + if err := r.attachChannelModelPriceTiers([]*model.ChannelModel{&item}); err != nil { + return nil, err + } + return &item, nil +} + +func (r *Repository) SaveChannelModel(item *model.ChannelModel) error { + return r.db.Save(item).Error +} + +// SaveChannelModelWithPriceTiers 原子保存系统模型与其活动价格档。移除价格档采用软删除, +// 让已结算订单的 PriceTierID 仍能回溯到原始配置版本。 +func (r *Repository) SaveChannelModelWithPriceTiers(item *model.ChannelModel, tiers []model.ChannelModelPriceTier) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var existing []model.ChannelModelPriceTier + if err := tx.Where("channel_model_id = ?", item.ID).Find(&existing).Error; err != nil { + return err + } + existingByKey := make(map[string]model.ChannelModelPriceTier, len(existing)) + for _, tier := range existing { + existingByKey[channelModelPriceTierKey(tier)] = tier + } + selected := make(map[string]bool, len(tiers)) + for index := range tiers { + tier := &tiers[index] + tier.ChannelModelID = item.ID + key := channelModelPriceTierKey(*tier) + if existingTier, exists := existingByKey[key]; exists { + tier.ID = existingTier.ID + tier.PriceVersion = existingTier.PriceVersion + 1 + if err := tx.Save(tier).Error; err != nil { + return err + } + } else if err := tx.Create(tier).Error; err != nil { + return err + } + selected[tier.ID] = true + } + for _, tier := range existing { + if selected[tier.ID] { + continue + } + if err := tx.Delete(&tier).Error; err != nil { + return err + } + } + if err := tx.Save(item).Error; err != nil { + return err + } + return nil + }) +} + +func channelModelPriceTierKey(tier model.ChannelModelPriceTier) string { + if strings.TrimSpace(tier.SelectorKey) != "" { + return tier.SelectorKey + } + _, key, err := model.CanonicalSKUSelector(map[string]string{ + "vquality": strings.TrimSpace(tier.Resolution), + "videoSeconds": strconv.Itoa(tier.VideoSeconds), + }) + if err != nil { + return "{}" + } + return key +} + +func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) error { + if len(items) == 0 { + return nil + } + ids := make([]string, 0, len(items)) + for _, item := range items { + ids = append(ids, item.ID) + } + var tiers []model.ChannelModelPriceTier + if r.db.Migrator().HasTable(&model.ChannelModelPriceTier{}) { + if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil { + return err + } + } + for index := range tiers { + tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON) + } + tiersByModelID := make(map[string][]model.ChannelModelPriceTier, len(items)) + for _, tier := range tiers { + tiersByModelID[tier.ChannelModelID] = append(tiersByModelID[tier.ChannelModelID], tier) + } + for _, item := range items { + item.PriceTiers = tiersByModelID[item.ID] + // 兼容尚未执行价格档回填的旧数据库;正式迁移会将同一数据持久化为默认档。 + if len(item.PriceTiers) == 0 && item.PriceConfigured { + item.PriceTiers = []model.ChannelModelPriceTier{{ + ChannelModelID: item.ID, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*", + ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode, + UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, + OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, + PriceConfigured: item.PriceConfigured, Enabled: item.Enabled, PriceVersion: item.PriceVersion, + }} + } + } + return nil +} + +// PopulateChannelModelPriceTiers 将价格档附着到已经查询出的渠道模型,供路由关系图批量加载使用。 +func (r *Repository) PopulateChannelModelPriceTiers(items []model.ChannelModel) error { + pointers := make([]*model.ChannelModel, len(items)) + for index := range items { + pointers[index] = &items[index] + } + return r.attachChannelModelPriceTiers(pointers) +} + +func (r *Repository) PopulateChannelModelPriceTier(item *model.ChannelModel) error { + if item == nil { + return nil + } + return r.attachChannelModelPriceTiers([]*model.ChannelModel{item}) +} + +func (r *Repository) DeleteChannelModel(channelID string, id string, now time.Time) error { + deleted, err := r.DeleteChannelModels(channelID, []string{id}, now) + if err != nil { + return err + } + if deleted != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +// DeleteChannelModels atomically removes a selection and refreshes the channel's +// compatibility model list. Any active route or task reference aborts the whole +// transaction so a bulk action cannot leave the administrator with a partial result. +func (r *Repository) DeleteChannelModels(channelID string, ids []string, now time.Time) (int64, error) { + ids = uniqueStrings(ids) + if len(ids) == 0 { + return 0, gorm.ErrRecordNotFound + } + var deleted int64 + err := r.db.Transaction(func(tx *gorm.DB) error { + if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { + return err + } + var existing int64 + if err := tx.Model(&model.ChannelModel{}). + Where("channel_id = ? AND id IN ?", channelID, ids). + Count(&existing).Error; err != nil { + return err + } + if existing != int64(len(ids)) { + return gorm.ErrRecordNotFound + } + var activeReferences int64 + if err := tx.Table("logical_model_routes AS route"). + Joins("JOIN logical_models AS logical_model ON logical_model.active_revision_id = route.logical_model_revision_id"). + Where("route.channel_model_id IN ?", ids). + Count(&activeReferences).Error; err != nil { + return err + } + if activeReferences > 0 { + return ErrChannelModelInUse + } + if err := tx.Model(&model.Task{}). + Where("channel_model_id IN ? AND status IN ?", ids, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeReferences).Error; err != nil { + return err + } + if activeReferences > 0 { + return ErrChannelModelInUse + } + result := tx.Model(&model.ChannelModel{}). + Where("id IN ? AND channel_id = ?", ids, channelID). + Updates(map[string]any{"enabled": false, "price_version": gorm.Expr("price_version + 1"), "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != int64(len(ids)) { + return gorm.ErrRecordNotFound + } + if err := tx.Where("id IN ? AND channel_id = ?", ids, channelID).Delete(&model.ChannelModel{}).Error; err != nil { + return err + } + if err := refreshChannelModelNames(tx, channelID, now); err != nil { + return err + } + deleted = result.RowsAffected + return nil + }) + return deleted, err +} + +// SyncChannelModelNames serializes compatibility-list refreshes for a system +// channel and derives the list from the committed channel_models rows. Callers +// must not pass a previously-read snapshot because concurrent administrator +// writes could otherwise overwrite a newer catalog. +func (r *Repository) SyncChannelModelNames(channelID string, now time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := r.lockSystemChannelForModelMutation(tx, channelID); err != nil { + return err + } + return refreshChannelModelNames(tx, channelID, now) + }) +} + +func (r *Repository) lockSystemChannelForModelMutation(tx *gorm.DB, channelID string) error { + query := tx.Select("id").Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + var channel model.ModelChannel + return query.First(&channel).Error +} + +func refreshChannelModelNames(tx *gorm.DB, channelID string, now time.Time) error { + var names []string + if err := tx.Model(&model.ChannelModel{}). + Where("channel_id = ? AND enabled = ?", channelID, true). + Order("sort_order asc, created_at asc, id asc"). + Pluck("model_key", &names).Error; err != nil { + return err + } + encoded, err := json.Marshal(names) + if err != nil { + return err + } + return tx.Model(&model.ModelChannel{}). + Where("id = ? AND scope = ?", channelID, model.ChannelScopeSystem). + Updates(map[string]any{"models_json": string(encoded), "updated_at": now}).Error +} + +func (r *Repository) CreateMissingChannelModels(items []model.ChannelModel) (int64, error) { + if len(items) == 0 { + return 0, nil + } + // 拉取目录可能与其他管理员操作并发,唯一键冲突时保留已有定价配置。 + result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items) + return result.RowsAffected, result.Error +} diff --git a/backend/internal/repository/finance_redeem.go b/backend/internal/repository/finance_redeem.go new file mode 100644 index 000000000..9bae1a5f1 --- /dev/null +++ b/backend/internal/repository/finance_redeem.go @@ -0,0 +1,185 @@ +// 积分调整与兑换码批次。 + +package repository + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) AdjustCredits(userID string, actorUserID string, amount int64, note string) (*model.CreditAccount, error) { + var account model.CreditAccount + err := r.db.Transaction(func(tx *gorm.DB) error { + account = model.CreditAccount{UserID: userID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { + return err + } + accountQuery := tx.Model(&model.CreditAccount{}).Where("user_id = ?", userID) + if amount < 0 { + accountQuery = accountQuery.Where("available_microcredits + ? >= 0", amount) + } + updated := accountQuery. + Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits + ?", amount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrInsufficientCredits + } + if err := tx.First(&account, "user_id = ?", userID).Error; err != nil { + return err + } + entryType := model.CreditLedgerAdminAdjust + if amount > 0 { + entryType = model.CreditLedgerAdminGrant + } + return tx.Create(&model.CreditLedgerEntry{ + ID: newRepositoryID(), + UserID: userID, + Type: entryType, + AmountMicrocredits: amount, + AvailableDeltaMicrocredits: amount, + AvailableAfterMicrocredits: account.AvailableMicrocredits, + ReservedAfterMicrocredits: account.ReservedMicrocredits, + ActorUserID: actorUserID, + Note: note, + }).Error + }) + return &account, err +} + +func (r *Repository) CreateRedeemBatch(batch *model.RedeemBatch, codes []model.RedeemCode) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(batch).Error; err != nil { + return err + } + return tx.CreateInBatches(&codes, 200).Error + }) +} + +func (r *Repository) AdminRedeemBatches(keyword string, validity string, limit int, offset int) ([]model.RedeemBatch, int64, error) { + var items []model.RedeemBatch + var total int64 + query := r.db.Model(&model.RedeemBatch{}) + if value := strings.TrimSpace(keyword); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Where("lower(note) LIKE ? OR CAST(amount_microcredits AS TEXT) LIKE ? OR CAST(count AS TEXT) LIKE ?", pattern, pattern, pattern) + } + if validity == "active" { + query = query.Where("expires_at IS NULL OR expires_at > ?", time.Now()) + } else if validity == "expired" { + query = query.Where("expires_at IS NOT NULL AND expires_at <= ?", time.Now()) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + now := time.Now() + listQuery := query.Select(`redeem_batches.id, redeem_batches.amount_microcredits, redeem_batches.count, + redeem_batches.note, redeem_batches.created_by, redeem_batches.expires_at, redeem_batches.created_at, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND (rc.expires_at IS NULL OR rc.expires_at > ?)) AS available_count, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'redeemed') AS redeemed_count, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'disabled') AS disabled_count, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND rc.expires_at IS NOT NULL AND rc.expires_at <= ?) AS expired_count`, now, now) + if err := listQuery.Order("created_at desc").Limit(limit).Offset(offset).Find(&items).Error; err != nil { + return nil, 0, err + } + return items, total, nil +} + +func (r *Repository) RedeemBatch(id string) (*model.RedeemBatch, error) { + var batch model.RedeemBatch + now := time.Now() + query := r.db.Model(&model.RedeemBatch{}).Select(`redeem_batches.*, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND (rc.expires_at IS NULL OR rc.expires_at > ?)) AS available_count, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'redeemed') AS redeemed_count, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'disabled') AS disabled_count, + (SELECT COUNT(*) FROM redeem_codes rc WHERE rc.batch_id = redeem_batches.id AND rc.status = 'unused' AND rc.expires_at IS NOT NULL AND rc.expires_at <= ?) AS expired_count`, now, now) + if err := query.First(&batch, "redeem_batches.id = ?", id).Error; err != nil { + return nil, err + } + return &batch, nil +} + +func (r *Repository) AdminRedeemCodes(batchID string, status string, limit int, offset int) ([]AdminRedeemCodeRow, int64, error) { + var items []AdminRedeemCodeRow + var total int64 + query := r.db.Model(&model.RedeemCode{}).Where("redeem_codes.batch_id = ?", batchID) + now := time.Now() + switch status { + case "available": + query = query.Where("redeem_codes.status = ? AND (redeem_codes.expires_at IS NULL OR redeem_codes.expires_at > ?)", model.RedeemCodeUnused, now) + case "redeemed": + query = query.Where("redeem_codes.status = ?", model.RedeemCodeRedeemed) + case "disabled": + query = query.Where("redeem_codes.status = ?", model.RedeemCodeDisabled) + case "expired": + query = query.Where("redeem_codes.status = ? AND redeem_codes.expires_at IS NOT NULL AND redeem_codes.expires_at <= ?", model.RedeemCodeUnused, now) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + err := query.Select("redeem_codes.*, users.username AS redeemed_username, users.display_name AS redeemed_display_name"). + Joins("LEFT JOIN users ON users.id = redeem_codes.redeemed_by"). + Order("redeem_codes.created_at asc, redeem_codes.id asc").Limit(limit).Offset(offset).Scan(&items).Error + return items, total, err +} + +func (r *Repository) RedeemCode(userID string, codeHash string, redeemedIP string) (*model.CreditAccount, error) { + var account model.CreditAccount + err := r.db.Transaction(func(tx *gorm.DB) error { + var code model.RedeemCode + if err := tx.First(&code, "code_hash = ?", codeHash).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrRedeemCodeInvalid + } + return err + } + now := time.Now() + query := tx.Model(&model.RedeemCode{}).Where("id = ? AND status = ?", code.ID, model.RedeemCodeUnused) + if code.ExpiresAt != nil { + query = query.Where("expires_at > ?", now) + } + updated := query.Updates(map[string]any{"status": model.RedeemCodeRedeemed, "redeemed_by": userID, "redeemed_at": &now, "redeemed_ip": redeemedIP, "updated_at": now}) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrRedeemCodeInvalid + } + account = model.CreditAccount{UserID: userID} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&account).Error; err != nil { + return err + } + if err := tx.Model(&model.CreditAccount{}).Where("user_id = ?", userID).Updates(map[string]any{ + "available_microcredits": gorm.Expr("available_microcredits + ?", code.AmountMicrocredits), + "version": gorm.Expr("version + 1"), + "updated_at": now, + }).Error; err != nil { + return err + } + if err := tx.First(&account, "user_id = ?", userID).Error; err != nil { + return err + } + return tx.Create(&model.CreditLedgerEntry{ + ID: newRepositoryID(), + UserID: userID, + Type: model.CreditLedgerRedeem, + AmountMicrocredits: code.AmountMicrocredits, + AvailableDeltaMicrocredits: code.AmountMicrocredits, + AvailableAfterMicrocredits: account.AvailableMicrocredits, + ReservedAfterMicrocredits: account.ReservedMicrocredits, + RedeemCodeID: code.ID, + Note: "兑换码充值", + }).Error + }) + return &account, err +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 2ae0e9a80..b69c769aa 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -2,19 +2,13 @@ package repository import ( "context" - "encoding/json" "errors" - "fmt" - "slices" - "sort" "strings" "time" + "gorm.io/gorm" "infinite-canvas/backend/internal/database" "infinite-canvas/backend/internal/model" - - "gorm.io/gorm" - "gorm.io/gorm/clause" ) var ErrDailyUploadLimitExceeded = errors.New("今日上传额度已用完,请明天再试") @@ -155,2346 +149,3 @@ func (r *Repository) Delete(value any, query any, args ...any) error { conds := append([]any{query}, args...) return r.db.Delete(value, conds...).Error } - -func (r *Repository) UserCount() (int64, error) { - var count int64 - err := r.db.Model(&model.User{}).Count(&count).Error - return count, err -} - -func (r *Repository) User(id string) (*model.User, error) { - var user model.User - if err := r.db.First(&user, "id = ?", id).Error; err != nil { - return nil, err - } - return &user, nil -} - -func (r *Repository) UserByAccount(account string) (*model.User, error) { - var user model.User - if err := r.db.Where("lower(username) = lower(?) OR lower(email) = lower(?)", account, account).First(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - -func (r *Repository) UserByUsername(username string) (*model.User, error) { - var user model.User - if err := r.db.Where("lower(username) = lower(?)", username).First(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - -func (r *Repository) UserByEmail(email string) (*model.User, error) { - var user model.User - if err := r.db.Where("email <> '' AND lower(email) = lower(?)", email).First(&user).Error; err != nil { - return nil, err - } - return &user, nil -} - -func (r *Repository) Users() ([]model.User, error) { - var users []model.User - err := r.db.Order("created_at desc").Find(&users).Error - return users, err -} - -func (r *Repository) AdminUsers(keyword string, role model.UserRole, status model.UserStatus, limit int, offset int) ([]model.User, int64, error) { - var users []model.User - var total int64 - query := r.db.Model(&model.User{}) - if value := strings.TrimSpace(keyword); value != "" { - pattern := "%" + strings.ToLower(value) + "%" - query = query.Where("lower(username) LIKE ? OR lower(display_name) LIKE ? OR lower(email) LIKE ?", pattern, pattern, pattern) - } - if role == model.UserRoleAdmin || role == model.UserRoleUser { - query = query.Where("role = ?", role) - } - if status == model.UserStatusActive || status == model.UserStatusDisabled { - query = query.Where("status = ?", status) - } - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil { - return nil, 0, err - } - return users, total, nil -} - -func (r *Repository) AdminUserReferences() ([]model.User, error) { - var users []model.User - err := r.db.Select("id", "username", "display_name").Order("created_at desc").Limit(100).Find(&users).Error - return users, err -} - -func (r *Repository) ActiveAdminCountExcluding(userID string) (int64, error) { - var count int64 - query := r.db.Model(&model.User{}).Where("role = ? AND status = ?", model.UserRoleAdmin, model.UserStatusActive) - if userID != "" { - query = query.Where("id <> ?", userID) - } - err := query.Count(&count).Error - return count, err -} - -func (r *Repository) AuthSession(id string) (*model.AuthSession, error) { - var session model.AuthSession - if err := r.db.First(&session, "id = ?", id).Error; err != nil { - return nil, err - } - return &session, nil -} - -func (r *Repository) DeleteAuthSession(id string) error { - return r.db.Delete(&model.AuthSession{}, "id = ?", id).Error -} - -func (r *Repository) DeleteExpiredAuthSessions() error { - return r.db.Delete(&model.AuthSession{}, "expires_at <= ?", time.Now()).Error -} - -func (r *Repository) DeleteUserAuthSessions(userID string) error { - return r.db.Delete(&model.AuthSession{}, "user_id = ?", userID).Error -} - -func (r *Repository) LatestEmailVerificationCode(email string, purpose string) (*model.EmailVerificationCode, error) { - var code model.EmailVerificationCode - if err := r.db.Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose).Order("created_at desc").First(&code).Error; err != nil { - return nil, err - } - return &code, nil -} - -func (r *Repository) MarkEmailVerificationCodeUsed(id string, usedAt time.Time) error { - return r.db.Model(&model.EmailVerificationCode{}).Where("id = ? AND used_at IS NULL", id).Update("used_at", usedAt).Error -} - -func (r *Repository) DeleteEmailVerificationCode(id string) error { - return r.db.Delete(&model.EmailVerificationCode{}, "id = ?", id).Error -} - -func (r *Repository) CreateUserWithEmailVerification(user *model.User, verificationCodeID string, usedAt time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.EmailVerificationCode{}).Where("id = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, usedAt).Update("used_at", usedAt) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return errors.New("邮箱验证码已失效,请重新获取") - } - return tx.Create(user).Error - }) -} - -func (r *Repository) ResetUserPasswordWithEmailVerification(userID string, email string, purpose string, verificationCodeID string, passwordHash string, usedAt time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { - codeResult := tx.Model(&model.EmailVerificationCode{}). - Where("id = ? AND email = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, email, purpose, usedAt). - Update("used_at", usedAt) - if codeResult.Error != nil { - return codeResult.Error - } - if codeResult.RowsAffected != 1 { - return ErrEmailVerificationCodeInvalid - } - - userResult := tx.Model(&model.User{}). - Where("id = ? AND email <> '' AND lower(email) = lower(?) AND status = ? AND password_hash <> ''", userID, email, model.UserStatusActive). - Updates(map[string]any{"password_hash": passwordHash, "updated_at": usedAt}) - if userResult.Error != nil { - return userResult.Error - } - if userResult.RowsAffected != 1 { - return ErrEmailVerificationCodeInvalid - } - if err := tx.Delete(&model.AuthSession{}, "user_id = ?", userID).Error; err != nil { - return err - } - return tx.Model(&model.EmailVerificationCode{}). - Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose). - Update("used_at", usedAt).Error - }) -} - -func (r *Repository) DeleteExpiredEmailVerificationCodes(now time.Time) error { - return r.db.Delete(&model.EmailVerificationCode{}, "expires_at <= ? OR used_at IS NOT NULL", now).Error -} - -func (r *Repository) Task(id string) (*model.Task, error) { - var task model.Task - if err := r.db.First(&task, "id = ?", id).Error; err != nil { - return nil, err - } - return &task, nil -} - -func (r *Repository) TaskForUser(userID string, id string) (*model.Task, error) { - var task model.Task - if err := r.db.First(&task, "id = ? AND user_id = ?", id, userID).Error; err != nil { - return nil, err - } - return &task, nil -} - -func (r *Repository) ActiveTaskCountForUser(userID string) (int64, error) { - var count int64 - err := r.db.Model(&model.Task{}).Where("user_id = ? AND status IN ?", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&count).Error - return count, err -} - -func (r *Repository) ActiveTaskCountForProjectIDs(userID string, projectIDs []string) (int64, error) { - if len(projectIDs) == 0 { - return 0, nil - } - var count int64 - err := r.db.Model(&model.Task{}). - Where("user_id = ? AND project_id IN ? AND status IN ?", userID, projectIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). - Count(&count).Error - return count, err -} - -// 任务领取以数据库租约为真相;PostgreSQL 锁行跳过竞争任务,SQLite 继续依赖条件更新保证单实例原子性。 -func (r *Repository) ClaimNextTask(owner string, leaseDuration time.Duration) (*model.Task, error) { - var task model.Task - now := time.Now() - leaseExpiresAt := now.Add(leaseDuration) - err := r.db.Transaction(func(tx *gorm.DB) error { - query := tx.Where("(status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))) AND (next_poll_at IS NULL OR next_poll_at <= ?)", model.TaskStatusQueued, model.TaskStatusRunning, now, now). - Order("created_at asc").Limit(1) - if r.Dialect() == "postgres" { - query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}) - } - result := query.Find(&task) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - task = model.Task{} - return nil - } - claim := tx.Model(&model.Task{}).Where("id = ?", task.ID) - if r.Dialect() != "postgres" { - claim = claim.Where("(status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))) AND (next_poll_at IS NULL OR next_poll_at <= ?)", model.TaskStatusQueued, model.TaskStatusRunning, now, now) - } - updated := claim. - Updates(map[string]any{ - "status": model.TaskStatusRunning, - "stage": "后端接管任务", - "progress": 15, - "attempts": gorm.Expr("attempts + ?", 1), - "started_at": gorm.Expr("COALESCE(started_at, ?)", now), - "lease_owner": owner, - "lease_expires_at": leaseExpiresAt, - "next_poll_at": nil, - "updated_at": now, - }) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected == 0 { - task = model.Task{} - return nil - } - return tx.First(&task, "id = ?", task.ID).Error - }) - if err != nil || task.ID == "" { - return nil, err - } - return &task, nil -} - -func (r *Repository) RenewTaskLease(id string, owner string, leaseDuration time.Duration) error { - result := r.db.Model(&model.Task{}). - Where("id = ? AND status = ? AND lease_owner = ? AND lease_expires_at > ?", id, model.TaskStatusRunning, owner, time.Now()). - Updates(map[string]any{"lease_expires_at": time.Now().Add(leaseDuration), "updated_at": time.Now()}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return errors.New("任务租约已失效") - } - return nil -} - -func (r *Repository) UpdateTaskProviderState(id string, providerRequestID string, pollStage string, nextPollAt *time.Time) error { - updates := map[string]any{"poll_stage": pollStage, "next_poll_at": nextPollAt, "updated_at": time.Now()} - if strings.TrimSpace(providerRequestID) != "" { - updates["provider_request_id"] = strings.TrimSpace(providerRequestID) - } - return r.db.Model(&model.Task{}).Where("id = ?", id).Updates(updates).Error -} - -func (r *Repository) DeferRunningTaskForProviderPoll(id string, owner string, stage string, delay time.Duration) error { - now := time.Now() - result := taskLeaseWriter(r.db.Model(&model.Task{}), owner). - Where("id = ? AND status = ?", id, model.TaskStatusRunning). - Updates(map[string]any{ - "stage": stage, "error": "", "completed_at": nil, "next_poll_at": now.Add(delay), - "lease_owner": "", "lease_expires_at": nil, "updated_at": now, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrTaskStateConflict - } - return nil -} - -// 人工恢复仅锁定失败任务;旧 worker 的租约可覆盖,但未过期的人工恢复租约不能并发抢占。 -func (r *Repository) ClaimFailedTaskProviderRecovery(id string, userID string, owner string, leaseDuration time.Duration) error { - now := time.Now() - query := r.db.Model(&model.Task{}).Where( - "id = ? AND status = ? AND (lease_owner = '' OR lease_owner NOT LIKE ? OR lease_expires_at IS NULL OR lease_expires_at <= ?)", - id, model.TaskStatusFailed, "manual-recovery:%", now, - ) - if strings.TrimSpace(userID) != "" { - query = query.Where("user_id = ?", userID) - } - result := query.Updates(map[string]any{ - "lease_owner": owner, - "lease_expires_at": now.Add(leaseDuration), - "updated_at": now, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrTaskProviderRecoveryConflict - } - return nil -} - -func (r *Repository) ReleaseTaskProviderRecovery(id string, owner string) error { - return r.db.Model(&model.Task{}). - Where("id = ? AND lease_owner = ?", id, owner). - Updates(map[string]any{"lease_owner": "", "lease_expires_at": nil, "updated_at": time.Now()}).Error -} - -func (r *Repository) UpdateTaskProgress(id string, stage string, progress int) error { - return r.db.Model(&model.Task{}).Where("id = ? AND status = ?", id, model.TaskStatusRunning).Updates(map[string]any{ - "stage": stage, "progress": progress, "updated_at": time.Now(), - }).Error -} - -func (r *Repository) UpdateTaskProgressForLease(id string, owner string, stage string, progress int) error { - result := taskLeaseWriter(r.db.Model(&model.Task{}), owner). - Where("id = ? AND status = ?", id, model.TaskStatusRunning). - Updates(map[string]any{"stage": stage, "progress": progress, "updated_at": time.Now()}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrTaskStateConflict - } - return nil -} - -// 无租约任务仅能写无租约记录;有租约的执行者必须仍持有本次领取的有效 owner。 -func taskLeaseWriter(db *gorm.DB, owner string) *gorm.DB { - if owner == "" { - return db.Where("(lease_owner = '' OR lease_owner IS NULL)") - } - return db.Where("lease_owner = ? AND lease_expires_at > ?", owner, time.Now()) -} - -// UpdateTaskProviderProgress records upstream-reported progress without allowing -// a delayed or out-of-order poll response to move the public percentage backwards. -func (r *Repository) UpdateTaskProviderProgress(id string, progress int) error { - progress = max(0, min(100, progress)) - return r.db.Model(&model.Task{}).Where("id = ? AND status = ?", id, model.TaskStatusRunning).Updates(map[string]any{ - "stage": "上游生成中", - "progress": gorm.Expr("CASE WHEN progress < ? THEN ? ELSE progress END", progress, progress), - "updated_at": time.Now(), - }).Error -} - -func (r *Repository) SaveTaskCompletion(task *model.Task, expected model.TaskStatus, results []model.Result) error { - return r.SaveTaskCompletionWithRegistration(task, expected, results, nil) -} - -// Registration and terminal success commit together; a failed asset write leaves -// the task recoverable and never exposes a false success to the canvas. -func (r *Repository) SaveTaskCompletionWithRegistration(task *model.Task, expected model.TaskStatus, results []model.Result, register func(*Repository) error) error { - return r.db.Transaction(func(tx *gorm.DB) error { - updated := taskLeaseWriter(tx.Model(&model.Task{}), task.LeaseOwner). - Where("id = ? AND status = ?", task.ID, expected). - Select("*").Omit("id", "created_at").Updates(task) - if updated.Error != nil { - return updated.Error - } - if updated.RowsAffected != 1 { - return ErrTaskStateConflict - } - for index := range results { - if err := tx.Create(&results[index]).Error; err != nil { - return err - } - } - if register != nil { - return register(New(tx)) - } - return nil - }) -} - -func (r *Repository) UpdateTaskTerminalState(id string, owner string, expected model.TaskStatus, status model.TaskStatus, stage string, errorText string, completedAt time.Time) (bool, error) { - result := taskLeaseWriter(r.db.Model(&model.Task{}), owner). - Where("id = ? AND status = ?", id, expected). - Updates(map[string]any{ - "status": status, "stage": stage, "error": errorText, "completed_at": &completedAt, - "lease_owner": "", "lease_expires_at": nil, "updated_at": completedAt, - }) - return result.RowsAffected == 1, result.Error -} - -func (r *Repository) UpdateTaskTerminalDiagnostic(task *model.Task, completedAt time.Time) (bool, error) { - result := taskLeaseWriter(r.db.Model(&model.Task{}), task.LeaseOwner). - Where("id = ? AND status = ?", task.ID, model.TaskStatusRunning). - Updates(map[string]any{ - "status": task.Status, "stage": task.Stage, "error": task.Error, "completed_at": &completedAt, - "execution_diagnostic_json": task.ExecutionDiagnosticJSON, - "cancellation_source": task.CancellationSource, "cancellation_actor_id": task.CancellationActorID, - "cancellation_requested_at": task.CancellationRequestedAt, - "lease_owner": "", "lease_expires_at": nil, "updated_at": completedAt, - }) - return result.RowsAffected == 1, result.Error -} - -func (r *Repository) UpdateTaskExecutionDiagnostic(userID, taskID, diagnosticJSON string) error { - result := r.db.Model(&model.Task{}). - Where("id = ? AND user_id = ?", taskID, userID). - Update("execution_diagnostic_json", diagnosticJSON) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return nil -} - -func (r *Repository) CancelTaskIfStatus(userID string, id string, expected model.TaskStatus, now time.Time, intents ...model.TaskCancellationIntent) (bool, error) { - updates := map[string]any{ - "status": model.TaskStatusCancelled, "stage": "任务已取消", "error": "任务已取消", "completed_at": &now, - "lease_owner": "", "lease_expires_at": nil, "updated_at": now, - } - if len(intents) > 0 { - intent := intents[0] - diagnostic, err := json.Marshal(intent.Diagnostic) - if err != nil { - return false, err - } - updates["cancellation_source"], updates["cancellation_actor_id"], updates["cancellation_requested_at"] = intent.Source, intent.ActorID, intent.RequestedAt - updates["execution_diagnostic_json"] = string(diagnostic) - } - result := r.db.Model(&model.Task{}). - Where("id = ? AND user_id = ? AND status = ?", id, userID, expected). - Updates(updates) - return result.RowsAffected == 1, result.Error -} - -// 上游取消先落库再发请求;条件更新保证并发和重复取消只有一个调用方取得发送权。 -func (r *Repository) ClaimTaskProviderCancellation(userID string, id string, now time.Time) error { - result := r.db.Model(&model.Task{}). - Where("id = ? AND user_id = ? AND status = ? AND provider_cancel_status = ''", id, userID, model.TaskStatusCancelled). - Updates(map[string]any{ - "provider_cancel_status": model.ProviderCancelStatusRequested, - "provider_cancel_attempts": 1, - "provider_cancel_requested_at": &now, - "provider_cancel_next_check_at": now.Add(15 * time.Second), - "provider_cancel_error": "", - "updated_at": now, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrTaskProviderCancellationConflict - } - return nil -} - -func (r *Repository) UpdateTaskProviderCancellation(id string, expected model.ProviderCancelStatus, status model.ProviderCancelStatus, errorText string, nextCheckAt *time.Time, cancelledAt *time.Time) error { - updates := map[string]any{ - "provider_cancel_status": status, - "provider_cancel_error": errorText, - "provider_cancel_next_check_at": nextCheckAt, - "lease_owner": "", - "lease_expires_at": nil, - "updated_at": time.Now(), - } - if cancelledAt != nil { - updates["provider_cancelled_at"] = cancelledAt - } - result := r.db.Model(&model.Task{}).Where("id = ? AND status = ? AND provider_cancel_status = ?", id, model.TaskStatusCancelled, expected).Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrTaskProviderCancellationConflict - } - return nil -} - -// 对账任务同样使用数据库租约,多实例和服务重启后只会有一个 worker 查询同一上游任务。 -func (r *Repository) ClaimNextTaskProviderCancellation(owner string, leaseDuration time.Duration) (*model.Task, error) { - var task model.Task - now := time.Now() - err := r.db.Transaction(func(tx *gorm.DB) error { - query := tx.Where( - "status = ? AND provider_cancel_status = ? AND (provider_cancel_next_check_at IS NULL OR provider_cancel_next_check_at <= ?) AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", - model.TaskStatusCancelled, model.ProviderCancelStatusRequested, now, now, - ).Order("provider_cancel_requested_at asc").Limit(1) - if r.Dialect() == "postgres" { - query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}) - } - if result := query.Find(&task); result.Error != nil || result.RowsAffected == 0 { - task = model.Task{} - return result.Error - } - claim := tx.Model(&model.Task{}).Where( - "id = ? AND status = ? AND provider_cancel_status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", - task.ID, model.TaskStatusCancelled, model.ProviderCancelStatusRequested, now, - ).Updates(map[string]any{ - "lease_owner": owner, - "lease_expires_at": now.Add(leaseDuration), - "provider_cancel_attempts": gorm.Expr("provider_cancel_attempts + 1"), - "updated_at": now, - }) - if claim.Error != nil || claim.RowsAffected == 0 { - task = model.Task{} - return claim.Error - } - return tx.First(&task, "id = ?", task.ID).Error - }) - if err != nil || task.ID == "" { - return nil, err - } - return &task, nil -} - -func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnly bool) ([]model.Task, error) { - var tasks []model.Task - if limit <= 0 || limit > 100 { - limit = 50 - } - query := r.db.Select("id", "project_id", "type", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at"). - Where("user_id = ?", userID) - if strings.TrimSpace(projectID) != "" { - query = query.Where("project_id = ?", strings.TrimSpace(projectID)) - } - if activeOnly { - query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) - } - err := query.Order("created_at desc").Limit(limit).Find(&tasks).Error - return tasks, err -} - -// SuccessfulWorkflowTasksForProject 返回需要补偿工作流产物的成功任务。 -// 不设分页,供项目详情读取时修复浏览器中断造成的历史断点。 -func (r *Repository) SuccessfulWorkflowTasksForProject(userID string, projectID string) ([]model.Task, error) { - var tasks []model.Task - err := r.db.Where("user_id = ? AND project_id = ? AND status = ?", userID, projectID, model.TaskStatusSucceeded). - Order("completed_at asc, created_at asc").Find(&tasks).Error - return tasks, err -} - -func (r *Repository) TaskLogs(userID string, taskID string) ([]model.TaskLog, error) { - var logs []model.TaskLog - err := r.db.Order("created_at asc").Find(&logs, "user_id = ? AND task_id = ?", userID, taskID).Error - return logs, err -} - -func (r *Repository) SystemChannels(includeDisabled bool) ([]model.ModelChannel, error) { - var channels []model.ModelChannel - query := r.db.Order("sort_order asc, created_at asc, id asc").Where("scope = ?", model.ChannelScopeSystem) - if !includeDisabled { - query = query.Where("enabled = ?", true) - } - err := query.Find(&channels).Error - return channels, err -} - -func (r *Repository) HistoricalSystemChannelReferences() ([]model.ModelChannel, error) { - var channels []model.ModelChannel - err := r.db.Unscoped().Select("id", "name").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error - return channels, err -} - -func (r *Repository) AdminSystemChannels(keyword string, status string, limit int, offset int) ([]model.ModelChannel, int64, error) { - var channels []model.ModelChannel - var total int64 - query := r.db.Model(&model.ModelChannel{}).Where("scope = ?", model.ChannelScopeSystem) - if value := strings.TrimSpace(keyword); value != "" { - pattern := "%" + strings.ToLower(value) + "%" - query = query.Where("lower(name) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern) - } - if status == "enabled" { - query = query.Where("enabled = ?", true) - } else if status == "disabled" { - query = query.Where("enabled = ?", false) - } - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - if err := query.Order("sort_order asc, created_at asc, id asc").Limit(limit).Offset(offset).Find(&channels).Error; err != nil { - return nil, 0, err - } - return channels, total, nil -} - -func (r *Repository) AdminSystemChannelReferences() ([]model.ModelChannel, error) { - var channels []model.ModelChannel - err := r.db.Select("id", "name", "enabled").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error - return channels, err -} - -func (r *Repository) SystemChannel(id string) (*model.ModelChannel, error) { - var channel model.ModelChannel - if err := r.db.First(&channel, "id = ? AND scope = ? AND enabled = ?", id, model.ChannelScopeSystem, true).Error; err != nil { - return nil, err - } - return &channel, nil -} - -func (r *Repository) AdminSystemChannel(id string) (*model.ModelChannel, error) { - var channel model.ModelChannel - if err := r.db.First(&channel, "id = ? AND scope = ?", id, model.ChannelScopeSystem).Error; err != nil { - return nil, err - } - return &channel, nil -} - -func (r *Repository) DeleteSystemChannel(id string) error { - now := time.Now() - return r.db.Transaction(func(tx *gorm.DB) error { - channelResult := tx.Model(&model.ModelChannel{}). - Where("id = ? AND scope = ?", id, model.ChannelScopeSystem). - Updates(map[string]any{"api_key": "", "secret_key": "", "enabled": false, "updated_at": now}) - if channelResult.Error != nil { - return channelResult.Error - } - if channelResult.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - if err := tx.Model(&model.ChannelModel{}).Where("channel_id = ?", id).Updates(map[string]any{"enabled": false, "updated_at": now}).Error; err != nil { - return err - } - if err := tx.Where("channel_id = ?", id).Delete(&model.ChannelModel{}).Error; err != nil { - return err - } - return tx.Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).Delete(&model.ModelChannel{}).Error - }) -} - -func (r *Repository) ApiCallLogs(userID string, admin bool, limit int) ([]model.ApiCallLog, error) { - var logs []model.ApiCallLog - if limit <= 0 || limit > 200 { - limit = 100 - } - query := r.db.Order("created_at desc").Limit(limit) - if !admin { - query = query.Where("user_id = ?", userID) - } - err := query.Omit("RequestBody", "ResponseBody").Find(&logs).Error - return logs, err -} - -func (r *Repository) SystemSetting(key string) (*model.SystemSetting, error) { - var setting model.SystemSetting - if err := r.db.First(&setting, "key = ?", key).Error; err != nil { - return nil, err - } - return &setting, nil -} - -func (r *Repository) SystemSettingOptional(key string) (*model.SystemSetting, error) { - var setting model.SystemSetting - if err := r.db.Where("key = ?", key).Limit(1).Find(&setting).Error; err != nil { - return nil, err - } - if setting.Key == "" { - return nil, nil - } - return &setting, nil -} - -func (r *Repository) SaveSystemSetting(setting *model.SystemSetting) error { - return r.db.Save(setting).Error -} - -func (r *Repository) SaveSystemSettings(settings ...*model.SystemSetting) error { - return r.db.Transaction(func(tx *gorm.DB) error { - for _, setting := range settings { - if err := tx.Save(setting).Error; err != nil { - return err - } - } - return nil - }) -} - -func (r *Repository) ArkPrivateAssetBinding(resourceID string, projectName string) (*model.ArkPrivateAssetBinding, error) { - var binding model.ArkPrivateAssetBinding - if err := r.db.Where("resource_id = ? AND project_name = ?", resourceID, projectName).First(&binding).Error; err != nil { - return nil, err - } - return &binding, nil -} - -// CreateArkPrivateAssetBinding establishes a single uploader for a resource -// and Ark Project. Other workers can wait for that binding instead of -// importing the same image repeatedly. -func (r *Repository) CreateArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) (bool, error) { - result := r.db.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "resource_id"}, {Name: "project_name"}}, - DoNothing: true, - }).Create(binding) - if result.Error != nil { - return false, result.Error - } - return result.RowsAffected == 1, nil -} - -func (r *Repository) SaveArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) error { - return r.db.Save(binding).Error -} - -func (r *Repository) DeleteSystemSetting(key string) error { - return r.db.Delete(&model.SystemSetting{}, "key = ?", key).Error -} - -func (r *Repository) LatestUserOSSSetting(userID string) (*model.UserOSSSetting, error) { - var setting model.UserOSSSetting - if err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").First(&setting).Error; err != nil { - return nil, err - } - return &setting, nil -} - -func (r *Repository) UserOSSSettingsForUser(userID string) ([]model.UserOSSSetting, error) { - var settings []model.UserOSSSetting - err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").Find(&settings).Error - return settings, err -} - -func (r *Repository) UserOSSSettingForUser(userID string, id string) (*model.UserOSSSetting, error) { - var setting model.UserOSSSetting - if err := r.db.First(&setting, "id = ? AND user_id = ?", id, userID).Error; err != nil { - return nil, err - } - return &setting, nil -} - -func (r *Repository) CreateUserOSSSetting(setting *model.UserOSSSetting) error { - return r.db.Create(setting).Error -} - -func (r *Repository) StorageLocation(id string) (*model.StorageLocation, error) { - var location model.StorageLocation - if err := r.db.First(&location, "id = ?", id).Error; err != nil { - return nil, err - } - return &location, nil -} - -func (r *Repository) StorageLocationByDigest(scope string, ownerID string, provider string, digest string) (*model.StorageLocation, error) { - var location model.StorageLocation - if err := r.db.First(&location, "scope = ? AND owner_id = ? AND provider = ? AND location_digest = ?", scope, ownerID, provider, digest).Error; err != nil { - return nil, err - } - return &location, nil -} - -func (r *Repository) StorageLocationHistoryCount(scope string, ownerID string) (int64, error) { - var count int64 - err := r.db.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ?", scope, ownerID).Count(&count).Error - return count, err -} - -func (r *Repository) StorageLocationResourceCount(id string) (int64, error) { - var count int64 - err := r.db.Model(&model.Resource{}).Where("storage_setting_id = ?", id).Count(&count).Error - return count, err -} - -func (r *Repository) CreateStorageLocation(location *model.StorageLocation) error { - return r.db.Create(location).Error -} - -func (r *Repository) SaveStorageLocation(location *model.StorageLocation) error { - return r.db.Save(location).Error -} - -func (r *Repository) ActivateStorageLocation(scope string, ownerID string, id string, active bool) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ? AND active = ?", scope, ownerID, true).Update("active", false).Error; err != nil { - return err - } - if !active { - return nil - } - result := tx.Model(&model.StorageLocation{}).Where("id = ? AND scope = ? AND owner_id = ?", id, scope, ownerID).Update("active", true) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return nil - }) -} - -func (r *Repository) ReserveDailyUpload(userID string, day string, size int64, limit int64) error { - usage := model.UserDailyUploadUsage{ID: userID + ":" + day, UserID: userID, Day: day} - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&usage).Error; err != nil { - return err - } - result := tx.Model(&model.UserDailyUploadUsage{}). - Where("id = ? AND bytes + ? < ?", usage.ID, size, limit). - Updates(map[string]any{"bytes": gorm.Expr("bytes + ?", size), "updated_at": time.Now()}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return ErrDailyUploadLimitExceeded - } - return nil - }) -} - -func (r *Repository) ReleaseDailyUpload(userID string, day string, size int64) error { - id := userID + ":" + day - return r.db.Model(&model.UserDailyUploadUsage{}). - Where("id = ?", id). - Updates(map[string]any{ - "bytes": gorm.Expr("CASE WHEN bytes >= ? THEN bytes - ? ELSE 0 END", size, size), - "updated_at": time.Now(), - }).Error -} - -func (r *Repository) UserStoredFileBytes(userID string) (int64, error) { - var total int64 - err := r.db.Raw(` - SELECT - COALESCE(( - SELECT SUM(physical_resources.size) - FROM ( - SELECT MAX(size) AS size - FROM resources - WHERE user_id = ? AND status = ? - GROUP BY COALESCE(NULLIF(provider, ''), 'local'), endpoint, bucket, object_key - ) AS physical_resources - ), 0) - `, userID, model.ResourceStatusReady).Scan(&total).Error - return total, err -} - -func (r *Repository) DailyUploadBytes(userID string, day string) (int64, error) { - var total int64 - err := r.db.Model(&model.UserDailyUploadUsage{}).Select("COALESCE(bytes, 0)").Where("user_id = ? AND day = ?", userID, day).Scan(&total).Error - return total, err -} - -func (r *Repository) CreateResource(resource *model.Resource) error { - return r.db.Create(resource).Error -} - -func (r *Repository) SaveResource(resource *model.Resource) error { - return r.db.Save(resource).Error -} - -// UpdateResourceThumbnail only updates a still-ready row and cannot resurrect a deleted resource. -func (r *Repository) UpdateResourceThumbnail(userID string, id string, values map[string]any) (bool, error) { - result := r.db.Model(&model.Resource{}). - Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusReady). - Updates(values) - return result.RowsAffected == 1, result.Error -} - -func (r *Repository) ResourceByUploadKey(userID string, uploadKey string) (*model.Resource, error) { - var resource model.Resource - if err := r.db.First(&resource, "user_id = ? AND upload_key = ?", userID, uploadKey).Error; err != nil { - return nil, err - } - return &resource, nil -} - -func (r *Repository) ClaimFailedResourceUpload(userID string, id string) (bool, error) { - result := r.db.Model(&model.Resource{}). - Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusFailed). - Updates(map[string]any{"status": model.ResourceStatusPending, "error": "", "updated_at": time.Now()}) - return result.RowsAffected == 1, result.Error -} - -func (r *Repository) DeleteResource(userID string, id string) error { - return r.db.Delete(&model.Resource{}, "id = ? AND user_id = ?", id, userID).Error -} - -func (r *Repository) Resource(id string) (*model.Resource, error) { - var resource model.Resource - if err := r.db.First(&resource, "id = ?", id).Error; err != nil { - return nil, err - } - return &resource, nil -} - -func (r *Repository) ResourceForUser(userID string, id string) (*model.Resource, error) { - var resource model.Resource - if err := r.db.First(&resource, "id = ? AND user_id = ?", id, userID).Error; err != nil { - return nil, err - } - return &resource, nil -} - -func (r *Repository) Resources(userID string, limit int) ([]model.Resource, error) { - var resources []model.Resource - if limit <= 0 || limit > 500 { - limit = 200 - } - err := r.db.Order("created_at desc").Limit(limit).Find(&resources, "user_id = ?", userID).Error - return resources, err -} - -// PlaybackPendingVideos 返回本地存储、就绪但尚无播放副本判定结果的视频 -// (H.264 需标记 none、H.265 需触发转码)。 -func (r *Repository) PlaybackPendingVideos(limit int) ([]model.Resource, error) { - var resources []model.Resource - if limit <= 0 || limit > 100 { - limit = 20 - } - err := r.db.Where("kind = ? AND status = ? AND provider = ? AND (playback_status = ? OR playback_status IS NULL)", - "video", model.ResourceStatusReady, "local", "").Order("created_at asc").Limit(limit).Find(&resources).Error - return resources, err -} - -// PlaybackNoneVideos 返回存量本地视频中旧逻辑遗留、停在 none 的行 -// (规则变更前 H.265/MPEG-4 Part 2 曾被误判为浏览器可播并落 none)。 -// 服务启动回填时对它们重新按 codec 判定,让判定规则变更覆盖规则变更前已导入的文件。 -func (r *Repository) PlaybackNoneVideos(limit int) ([]model.Resource, error) { - var resources []model.Resource - if limit <= 0 || limit > 100 { - limit = 20 - } - err := r.db.Where("kind = ? AND status = ? AND provider = ? AND playback_status = ?", - "video", model.ResourceStatusReady, "local", model.PlaybackStatusNone). - Order("created_at asc").Limit(limit).Find(&resources).Error - return resources, err -} - -// ClaimPlaybackTranscode 原子地把待判定(空/none)视频置为 processing,返回是否抢占成功。 -// 多实例或多 goroutine 并发转同一资源时仅一个能成功置位,其余返回 false 直接放弃, -// 避免重复转码同一份文件。 -func (r *Repository) ClaimPlaybackTranscode(id string) (bool, error) { - res := r.db.Model(&model.Resource{}). - Where("id = ? AND (playback_status = ? OR playback_status IS NULL OR playback_status = ?)", - id, "", model.PlaybackStatusNone). - Updates(map[string]any{"playback_status": model.PlaybackStatusProcessing, "playback_error": ""}) - if res.Error != nil { - return false, res.Error - } - return res.RowsAffected > 0, nil -} - -// ResetStuckPlaybackTranscodes 服务重启时把卡在 processing 的转码记录重置回待判定 -// (进程崩溃后转码 goroutine 随进程消亡,状态永远停在 processing)。 -func (r *Repository) ResetStuckPlaybackTranscodes() error { - return r.db.Model(&model.Resource{}). - Where("playback_status = ?", model.PlaybackStatusProcessing). - Updates(map[string]any{"playback_status": "", "playback_error": ""}).Error -} - -func (r *Repository) ResourceCleanupCandidates(incompleteBefore time.Time, readyBefore time.Time, limit int) ([]model.Resource, error) { - var resources []model.Resource - if limit <= 0 || limit > 500 { - limit = 100 - } - err := r.db.Where( - "(status IN ? AND updated_at <= ?) OR (status = ? AND created_at <= ?)", - []model.ResourceStatus{model.ResourceStatusPending, model.ResourceStatusFailed}, incompleteBefore, - model.ResourceStatusReady, readyBefore, - ).Order("created_at asc, id asc").Limit(limit).Find(&resources).Error - return resources, err -} -func (r *Repository) Assets(userID string) ([]model.Asset, error) { - var assets []model.Asset - err := r.db.Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error - return assets, err -} - -func (r *Repository) AssetSummaries(userID string) ([]model.Asset, error) { - var assets []model.Asset - err := r.db.Select("id", "folder_id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error - return assets, err -} - -func (r *Repository) AssetForUser(userID string, id string) (*model.Asset, error) { - var asset model.Asset - if err := r.db.First(&asset, "id = ? AND user_id = ?", id, userID).Error; err != nil { - return nil, err - } - return &asset, nil -} - -func (r *Repository) AssetsForUserIDs(userID string, ids []string) ([]model.Asset, error) { - if len(ids) == 0 { - return nil, nil - } - var assets []model.Asset - err := r.db.Find(&assets, "user_id = ? AND id IN ?", userID, ids).Error - return assets, err -} - -func (r *Repository) UpsertAsset(asset *model.Asset) error { - result := r.db.Model(&model.Asset{}). - Where("id = ? AND user_id = ?", asset.ID, asset.UserID). - Updates(map[string]any{"folder_id": asset.FolderID, "kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt}) - if result.Error != nil || result.RowsAffected > 0 { - return result.Error - } - return r.db.Create(asset).Error -} - -func (r *Repository) DeleteAsset(userID string, id string) error { - return r.DeleteAssetAndResources(userID, id, nil, nil, false) -} - -func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) { - var assets []model.Asset - if limit <= 0 { - limit = 100 - } - err := r.db.Where("status = ? AND updated_at <= ?", model.AssetVersionStatusArchived, cutoff). - Order("updated_at asc, id asc"). - Limit(limit). - Find(&assets).Error - return assets, err -} - -func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Delete(&model.Asset{}, "user_id = ?", userID).Error; err != nil { - return err - } - if len(assets) == 0 { - return nil - } - return tx.Create(&assets).Error - }) -} - -func (r *Repository) CanvasProjects(userID string) ([]model.CanvasProject, error) { - var projects []model.CanvasProject - err := r.db.Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error - return projects, err -} - -func (r *Repository) CanvasProjectSummaries(userID string) ([]model.CanvasProject, error) { - var projects []model.CanvasProject - err := r.db.Select("id", "title", "revision", "created_at", "updated_at").Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error - return projects, err -} - -func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.CanvasProject, error) { - var project model.CanvasProject - if err := r.db.First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil { - return nil, err - } - return &project, nil -} - -func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error { - expected := project.Revision - if expected < 0 { - return ErrCanvasRevisionConflict - } - if expected == 0 { - created := *project - created.Revision = 1 - result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&created) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrCanvasRevisionConflict - } - project.Revision = 1 - return nil - } - // The revision predicate and increment must be in the same SQL statement. - // A missing row is a conflict, never an invitation to recreate a deleted canvas. - result := r.db.Model(&model.CanvasProject{}). - Where("id = ? AND user_id = ? AND revision = ?", project.ID, project.UserID, expected). - Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt, "revision": expected + 1}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return ErrCanvasRevisionConflict - } - project.Revision = expected + 1 - return nil -} - -func (r *Repository) DeleteCanvasProject(userID string, id string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - // Serialize deletion with saves before reading the history IDs to remove. - if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND id = ?", userID, id).UpdateColumn("revision", gorm.Expr("revision")).Error; err != nil { - return err - } - var snapshotIDs []string - if err := tx.Model(&model.CanvasSnapshot{}).Where("user_id = ? AND canvas_id = ?", userID, id).Pluck("id", &snapshotIDs).Error; err != nil { - return err - } - if err := deleteCanvasSnapshots(tx, snapshotIDs); err != nil { - return err - } - if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil { - return err - } - if err := tx.Where("canvas_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil { - return err - } - // 任务和会话是审计记录,不随独立画布实体保留归属 ID,避免删除后继续挂住画布上下文。 - if err := tx.Model(&model.Task{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { - return err - } - return tx.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", id, userID).Error - }) -} - -func (r *Repository) Projects(userID string) ([]model.Project, error) { - var projects []model.Project - err := r.db.Where("user_id = ?", userID).Order("updated_at desc").Find(&projects).Error - return projects, err -} - -func (r *Repository) ProjectsPage(userID string, page int, pageSize int) ([]model.Project, int64, error) { - var projects []model.Project - var total int64 - query := r.db.Model(&model.Project{}).Where("user_id = ?", userID) - if err := query.Count(&total).Error; err != nil { - return nil, 0, err - } - if err := query.Order("updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&projects).Error; err != nil { - return nil, 0, err - } - return projects, total, nil -} - -func (r *Repository) ProjectForUser(userID string, id string) (*model.Project, error) { - var project model.Project - if err := r.db.First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil { - return nil, err - } - return &project, nil -} - -func (r *Repository) CreateProject(project *model.Project) error { - return r.db.Create(project).Error -} - -func (r *Repository) UpdateProject(project *model.Project) error { - return r.db.Model(&model.Project{}).Where("id = ? AND user_id = ?", project.ID, project.UserID).Updates(map[string]any{ - "name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType, - "description": project.Description, "cover_resource_id": project.CoverResourceID, - "style_preset_id": project.StylePresetID, "style_profile_json": project.StyleProfileJSON, - "default_image_model": project.DefaultImageModel, "default_video_model": project.DefaultVideoModel, - "status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt, - }).Error -} - -func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []model.CanvasProject) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var canvasIDs []string - if err := tx.Model(&model.CanvasProject{}). - Where("user_id = ? AND project_id = ?", userID, id). - Pluck("id", &canvasIDs).Error; err != nil { - return err - } - projectScopeIDs := append([]string{id}, canvasIDs...) - var activeTaskCount int64 - if err := tx.Model(&model.Task{}). - Where("user_id = ? AND project_id IN ? AND status IN ?", userID, projectScopeIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). - Count(&activeTaskCount).Error; err != nil { - return err - } - if activeTaskCount > 0 { - return ErrProjectHasActiveTasks - } - if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil { - return err - } - for _, canvas := range canvasUpdates { - result := tx.Model(&model.CanvasProject{}). - Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvas.ID, userID, id, canvas.Revision). - Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt, "revision": canvas.Revision + 1}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - } - var remainingCanvasCount int64 - if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, id).Count(&remainingCanvasCount).Error; err != nil { - return err - } - if remainingCanvasCount != 0 { - return fmt.Errorf("项目删除时仍有 %d 个画布关联未处理", remainingCanvasCount) - } - shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id) - if err := tx.Where("project_id = ?", id).Delete(&model.ProductionTaskLink{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.ShotArtifact{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.Shot{}).Error; err != nil { - return err - } - instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ?", id) - stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs) - if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil { - return err - } - if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.WorkflowInstance{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetLink{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetFolder{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ?", id).Delete(&model.ProjectUnit{}).Error; err != nil { - return err - } - if err := tx.Model(&model.Task{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { - return err - } - return tx.Delete(&model.Project{}, "id = ? AND user_id = ?", id, userID).Error - }) -} - -func (r *Repository) BumpProjectRevision(projectID string) error { - return r.db.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error -} - -func (r *Repository) ProjectUnits(projectID string) ([]model.ProjectUnit, error) { - var units []model.ProjectUnit - err := r.db.Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error - return units, err -} - -func (r *Repository) ProjectUnitSummaries(projectID string) ([]model.ProjectUnit, error) { - var units []model.ProjectUnit - err := r.db.Select("id", "project_id", "kind", "title", "word_count", "status", "position", "created_at", "updated_at").Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error - return units, err -} - -func (r *Repository) ProjectAssetCount(projectID string) (int64, error) { - var count int64 - err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ?", projectID).Count(&count).Error - return count, err -} - -func (r *Repository) CreateProjectUnit(unit *model.ProjectUnit) error { - return r.db.Create(unit).Error -} - -func (r *Repository) ImportProjectUnits(projectID string, units []model.ProjectUnit) error { - return r.db.Transaction(func(tx *gorm.DB) error { - // 分批写入仍处于同一事务,避免两千章导入超过 SQLite/PostgreSQL 单语句参数上限。 - if err := tx.CreateInBatches(&units, 100).Error; err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) -} - -func (r *Repository) ReorderProjectUnits(projectID string, unitIDs []string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - now := time.Now() - for position, unitID := range unitIDs { - result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unitID, projectID).Updates(map[string]any{"position": position, "updated_at": now}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": now}).Error - }) -} - -func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUnit, error) { - var unit model.ProjectUnit - if err := r.db.First(&unit, "id = ? AND project_id = ?", id, projectID).Error; err != nil { - return nil, err - } - return &unit, nil -} - -func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit, invalidateWorkflow bool) error { - return r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unit.ID, unit.ProjectID).Updates(map[string]any{ - "parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "word_count": unit.WordCount, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - if invalidateWorkflow { - if err := tx.Model(&model.ShotArtifact{}).Where("project_id = ? AND unit_id = ? AND status NOT IN ?", unit.ProjectID, unit.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": unit.UpdatedAt}).Error; err != nil { - return err - } - if err := invalidateUnitWorkflowTx(tx, unit.ProjectID, unit.ID, "story", unit.UpdatedAt); err != nil { - return err - } - } - return tx.Model(&model.Project{}).Where("id = ?", unit.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": unit.UpdatedAt}).Error - }) -} - -func (r *Repository) DeleteProjectUnit(projectID string, id string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.CanvasUnitLink{}).Error; err != nil { - return err - } - shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id) - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProductionTaskLink{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ShotArtifact{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.Shot{}).Error; err != nil { - return err - } - instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id) - stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs) - if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil { - return err - } - if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.WorkflowInstance{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { - return err - } - result := tx.Delete(&model.ProjectUnit{}, "id = ? AND project_id = ?", id, projectID) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) -} - -func (r *Repository) CanvasUnitLink(projectID string, canvasID string, unitID string) (*model.CanvasUnitLink, error) { - var link model.CanvasUnitLink - if err := r.db.First(&link, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID).Error; err != nil { - return nil, err - } - return &link, nil -} - -func (r *Repository) UpsertCanvasUnitLink(link *model.CanvasUnitLink) error { - result := r.db.Model(&model.CanvasUnitLink{}).Where("project_id = ? AND canvas_id = ? AND unit_id = ?", link.ProjectID, link.CanvasID, link.UnitID).Updates(map[string]any{"role": link.Role}) - if result.Error != nil || result.RowsAffected > 0 { - return result.Error - } - return r.db.Create(link).Error -} - -func (r *Repository) ProjectCanvasSummaries(userID string, projectID string) ([]model.CanvasProject, error) { - var canvases []model.CanvasProject - err := r.db.Select("id", "user_id", "project_id", "title", "revision", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error - return canvases, err -} - -func (r *Repository) ProjectCanvasDocuments(userID string, projectID string) ([]model.CanvasProject, error) { - var canvases []model.CanvasProject - err := r.db.Select("id", "title", "payload_json", "revision").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error - return canvases, err -} - -func (r *Repository) ProjectCanvasUnitLinks(projectID string) ([]model.CanvasUnitLink, error) { - var links []model.CanvasUnitLink - err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&links).Error - return links, err -} - -func (r *Repository) DeleteCanvasUnitLink(projectID string, canvasID string, unitID string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Delete(&model.CanvasUnitLink{}, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) -} - -func (r *Repository) AssignCanvasToProject(userID string, canvasID string, projectID string) error { - return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Updates(map[string]any{"project_id": projectID, "revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error -} - -func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time, revision int64) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("project_id = ? AND canvas_id = ?", projectID, canvasID).Delete(&model.CanvasUnitLink{}).Error; err != nil { - return err - } - result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvasID, userID, projectID, revision).Updates(map[string]any{ - "project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt, "revision": revision + 1, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error - }) -} - -func (r *Repository) ProjectAssets(userID string, projectID string) ([]model.Asset, error) { - var assets []model.Asset - err := r.db.Table("assets").Select("assets.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").Where("assets.user_id = ? AND project_asset_links.project_id = ?", userID, projectID).Order("assets.updated_at desc").Scan(&assets).Error - return assets, err -} - -func (r *Repository) ProjectAssetLinks(projectID string) ([]model.ProjectAssetLink, error) { - var links []model.ProjectAssetLink - err := r.db.Where("project_id = ?", projectID).Order("folder_id asc, position asc, created_at asc").Find(&links).Error - return links, err -} - -func (r *Repository) ProjectAssetLink(projectID string, assetID string) (*model.ProjectAssetLink, error) { - var link model.ProjectAssetLink - if err := r.db.First(&link, "project_id = ? AND asset_id = ?", projectID, assetID).Error; err != nil { - return nil, err - } - return &link, nil -} - -func (r *Repository) NextProjectAssetPosition(projectID string, folderID string) (int, error) { - var result struct{ Maximum int } - err := r.db.Model(&model.ProjectAssetLink{}). - Select("COALESCE(MAX(position), -1) AS maximum"). - Where("project_id = ? AND folder_id = ?", projectID, folderID). - Scan(&result).Error - return result.Maximum + 1, err -} - -func (r *Repository) MoveProjectAsset(projectID string, assetID string, folderID string, position int) error { - return r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.ProjectAssetLink{}). - Where("project_id = ? AND asset_id = ?", projectID, assetID). - Updates(map[string]any{"folder_id": folderID, "position": position}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", projectID). - Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) -} - -func (r *Repository) ProjectAssetFolders(projectID string) ([]model.ProjectAssetFolder, error) { - var folders []model.ProjectAssetFolder - err := r.db.Where("project_id = ?", projectID).Order("parent_id asc, position asc, created_at asc").Find(&folders).Error - return folders, err -} - -func (r *Repository) ProjectAssetFolder(projectID string, folderID string) (*model.ProjectAssetFolder, error) { - var folder model.ProjectAssetFolder - if err := r.db.First(&folder, "id = ? AND project_id = ?", folderID, projectID).Error; err != nil { - return nil, err - } - return &folder, nil -} - -func (r *Repository) CreateProjectAssetFolder(folder *model.ProjectAssetFolder) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(folder).Error; err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID). - Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error - }) -} - -func (r *Repository) UpdateProjectAssetFolder(folder *model.ProjectAssetFolder) error { - return r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.ProjectAssetFolder{}). - Where("id = ? AND project_id = ?", folder.ID, folder.ProjectID). - Updates(map[string]any{"parent_id": folder.ParentID, "name": folder.Name, "name_key": folder.NameKey, "style": folder.Style, "theme": folder.Theme, "position": folder.Position, "updated_at": folder.UpdatedAt}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID). - Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error - }) -} - -func (r *Repository) DeleteProjectAssetFolder(projectID string, folderID string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var childCount int64 - if err := tx.Model(&model.ProjectAssetFolder{}).Where("project_id = ? AND parent_id = ?", projectID, folderID).Count(&childCount).Error; err != nil { - return err - } - var assetCount int64 - if err := tx.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND folder_id = ?", projectID, folderID).Count(&assetCount).Error; err != nil { - return err - } - if childCount > 0 || assetCount > 0 { - return ErrProjectAssetFolderNotEmpty - } - result := tx.Delete(&model.ProjectAssetFolder{}, "id = ? AND project_id = ?", folderID, projectID) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", projectID). - Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) -} - -// LinkProjectAsset 将首版本、素材领域字段、项目引用和修订号原子提交,避免产生半关联资产。 -// 资产首次入库也在此事务内完成(service 层只做内存构造,不预落库), -// 事务失败时资产一并回滚,不再留下“有资产无链接”的孤儿资产。 -func (r *Repository) LinkProjectAsset(asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) (bool, error) { - createdLink := false - err := r.db.Transaction(func(tx *gorm.DB) error { - // 资产可能尚未落库(首次导入)或已存在(并发/重试),冲突幂等跳过。 - assetCreated := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(asset) - if assetCreated.Error != nil { - return assetCreated.Error - } - created := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "project_id"}, {Name: "asset_id"}}, DoNothing: true}).Create(link) - if created.Error != nil { - return created.Error - } - if created.RowsAffected == 0 { - return nil - } - createdLink = true - if version != nil { - if err := tx.Create(version).Error; err != nil { - return err - } - } - result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{ - "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - return tx.Model(&model.Project{}).Where("id = ?", link.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) - return createdLink, err -} - -func (r *Repository) DeleteProjectAssetLink(projectID string, assetID string) error { - return r.db.Delete(&model.ProjectAssetLink{}, "project_id = ? AND asset_id = ?", projectID, assetID).Error -} - -func (r *Repository) ProjectAssetShotReferenceCount(projectID string, assetID string) (int64, error) { - var count int64 - err := r.db.Table("shot_asset_references"). - Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). - Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id"). - Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID). - Count(&count).Error - return count, err -} - -func (r *Repository) ProjectAssetLinked(projectID string, assetID string) (bool, error) { - var count int64 - err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND asset_id = ?", projectID, assetID).Count(&count).Error - return count > 0, err -} - -func (r *Repository) AssetReferenceCount(assetID string) (int64, error) { - var projectLinks int64 - if err := r.db.Model(&model.ProjectAssetLink{}).Where("asset_id = ?", assetID).Count(&projectLinks).Error; err != nil { - return 0, err - } - var shotLinks int64 - err := r.db.Table("shot_asset_references").Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").Where("asset_versions.asset_id = ?", assetID).Count(&shotLinks).Error - return projectLinks + shotLinks, err -} - -func (r *Repository) UpdateAssetDomain(asset *model.Asset) error { - return r.db.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{"category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt}).Error -} - -func (r *Repository) AssetVersions(assetID string) ([]model.AssetVersion, error) { - var versions []model.AssetVersion - err := r.db.Where("asset_id = ?", assetID).Order("version desc").Find(&versions).Error - return versions, err -} - -func (r *Repository) ProjectAssetUsageRoles(projectID string, assetID string) ([]string, error) { - var shotRoles []string - if err := r.db.Table("shot_asset_references"). - Distinct("shot_asset_references.role"). - Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). - Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id"). - Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID). - Order("shot_asset_references.role asc"). - Pluck("shot_asset_references.role", &shotRoles).Error; err != nil { - return nil, err - } - var representationRoles []string - if err := r.db.Table("asset_representations"). - Distinct("asset_representations.role"). - Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id"). - Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id"). - Where("project_asset_links.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID). - Pluck("asset_representations.role", &representationRoles).Error; err != nil { - return nil, err - } - seen := make(map[string]struct{}, len(shotRoles)+len(representationRoles)) - for _, role := range append(shotRoles, representationRoles...) { - if role != "" { - seen[role] = struct{}{} - } - } - roles := make([]string, 0, len(seen)) - for role := range seen { - roles = append(roles, role) - } - sort.Strings(roles) - return roles, nil -} - -func (r *Repository) AssetVersionForProject(projectID string, versionID string) (*model.AssetVersion, error) { - var version model.AssetVersion - err := r.db.Table("asset_versions").Select("asset_versions.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").Where("project_asset_links.project_id = ? AND asset_versions.id = ?", projectID, versionID).First(&version).Error - if err != nil { - return nil, err - } - return &version, nil -} - -func (r *Repository) CreateAssetVersion(version *model.AssetVersion) error { - return r.db.Create(version).Error -} - -func (r *Repository) ProjectShots(projectID string) ([]model.Shot, error) { - var shots []model.Shot - err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, position asc").Find(&shots).Error - return shots, err -} - -func (r *Repository) SaveShot(shot *model.Shot, create bool) error { - if create { - return r.db.Create(shot).Error - } - return r.db.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ - "unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position, - "duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt, - }).Error -} - -// SaveShotWithRevision 原子保存镜头当前值、新版本和下游失效状态。 -func (r *Repository) SaveShotWithRevision(shot *model.Shot, revision *model.ShotRevision, create bool) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if create { - if err := tx.Create(shot).Error; err != nil { - return err - } - } else { - result := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ - "unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position, - "duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt, - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - } - var currentVersion int - if err := tx.Model(&model.ShotRevision{}).Where("shot_id = ?", shot.ID).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { - return err - } - revision.Version = currentVersion + 1 - if err := tx.Create(revision).Error; err != nil { - return err - } - shot.CurrentRevisionID = revision.ID - if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ - "current_revision_id": revision.ID, "description": shot.Description, "duration_ms": shot.DurationMs, - "status": shot.Status, "updated_at": shot.UpdatedAt, - }).Error; err != nil { - return err - } - if !create { - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shot.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": shot.UpdatedAt}).Error; err != nil { - return err - } - } - if err := invalidateUnitWorkflowTx(tx, shot.ProjectID, shot.UnitID, "storyboard", shot.UpdatedAt); err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", shot.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": shot.UpdatedAt}).Error - }) -} - -func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot, revisions []model.ShotRevision, references []model.ShotAssetReference, expectedShotIDs []string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if expectedShotIDs != nil { - var currentShotIDs []string - if err := tx.Model(&model.Shot{}).Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("id asc").Pluck("id", ¤tShotIDs).Error; err != nil { - return err - } - expected := append([]string(nil), expectedShotIDs...) - sort.Strings(expected) - if !slices.Equal(currentShotIDs, expected) { - return ErrProjectUnitShotsChanged - } - } - shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, unitID) - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.ShotArtifact{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND shot_id IN (?)", projectID, shotIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.Shot{}).Error; err != nil { - return err - } - if err := tx.Create(&shots).Error; err != nil { - return err - } - if len(revisions) > 0 { - if err := tx.Create(&revisions).Error; err != nil { - return err - } - } - if len(references) > 0 { - if err := tx.Create(&references).Error; err != nil { - return err - } - } - if err := invalidateUnitWorkflowTx(tx, projectID, unitID, "storyboard", time.Now()); err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error - }) -} - -func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Shot, error) { - var shot model.Shot - if err := r.db.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { - return nil, err - } - return &shot, nil -} - -func (r *Repository) ShotRevisionForShot(shotID string, revisionID string) (*model.ShotRevision, error) { - var revision model.ShotRevision - if err := r.db.First(&revision, "id = ? AND shot_id = ?", revisionID, shotID).Error; err != nil { - return nil, err - } - return &revision, nil -} - -// DeleteProjectShot 原子删除单个镜头的领域关联,并重新压紧同章节镜头顺序。 -func (r *Repository) DeleteProjectShot(projectID string, shotID string, updatedAt time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var shot model.Shot - if err := tx.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ProductionTaskLink{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ShotArtifact{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id = ?", shotID).Delete(&model.ShotRevision{}).Error; err != nil { - return err - } - if err := tx.Where("shot_id = ?", shotID).Delete(&model.ShotAssetReference{}).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { - return err - } - result := tx.Where("id = ? AND project_id = ?", shotID, projectID).Delete(&model.Shot{}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrRecordNotFound - } - var remaining []model.Shot - if err := tx.Select("id", "position").Where("project_id = ? AND unit_id = ?", projectID, shot.UnitID).Order("position asc, created_at asc, id asc").Find(&remaining).Error; err != nil { - return err - } - for position, item := range remaining { - if item.Position == position { - continue - } - if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", item.ID, projectID).Update("position", position).Error; err != nil { - return err - } - } - if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error - }) -} - -func (r *Repository) ProjectShotRevisions(projectID string) ([]model.ShotRevision, error) { - var revisions []model.ShotRevision - err := r.db.Table("shot_revisions").Select("shot_revisions.*"). - Joins("JOIN shots ON shots.id = shot_revisions.shot_id"). - Where("shots.project_id = ?", projectID). - Order("shots.unit_id asc, shots.position asc, shot_revisions.version asc").Scan(&revisions).Error - return revisions, err -} - -func (r *Repository) ProjectShotArtifacts(projectID string) ([]model.ShotArtifact, error) { - var artifacts []model.ShotArtifact - err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, shot_id asc, type asc, version asc").Find(&artifacts).Error - return artifacts, err -} - -func (r *Repository) CreateShotArtifact(artifact *model.ShotArtifact) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var currentVersion int - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { - return err - } - artifact.Version = currentVersion + 1 - if artifact.Selected { - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil { - return err - } - } - return tx.Create(artifact).Error - }) -} - -func (r *Repository) MarkShotArtifactsStale(shotID string, updatedAt time.Time) error { - return r.db.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error -} - -func (r *Repository) UpsertProductionTaskLink(link *model.ProductionTaskLink) error { - return r.db.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", link.TaskID, link.ShotID, link.ArtifactType).Assign(map[string]any{ - "project_id": link.ProjectID, "canvas_id": link.CanvasID, "unit_id": link.UnitID, "shot_id": link.ShotID, - "workflow_step_id": link.WorkflowStepID, "artifact_type": link.ArtifactType, "updated_at": link.UpdatedAt, - }).FirstOrCreate(link).Error -} - -func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReference) error { - result := r.db.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status}) - if result.Error != nil || result.RowsAffected > 0 { - return result.Error - } - return r.db.Create(reference).Error -} - -func (r *Repository) UpsertShotAssetReferenceAndInvalidate(projectID string, reference *model.ShotAssetReference, updatedAt time.Time) error { - return r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - if err := tx.Create(reference).Error; err != nil { - return err - } - } - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", reference.ShotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil { - return err - } - var shot model.Shot - if err := tx.First(&shot, "id = ? AND project_id = ?", reference.ShotID, projectID).Error; err != nil { - return err - } - if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error - }) -} - -func (r *Repository) DeleteShotAssetReferenceAndInvalidate(projectID string, shotID string, referenceID string, updatedAt time.Time) (bool, error) { - deleted := false - err := r.db.Transaction(func(tx *gorm.DB) error { - result := tx.Where("id = ? AND shot_id = ?", referenceID, shotID).Delete(&model.ShotAssetReference{}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return nil - } - deleted = true - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil { - return err - } - var shot model.Shot - if err := tx.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { - return err - } - if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error - }) - return deleted, err -} - -func invalidateUnitWorkflowTx(tx *gorm.DB, projectID string, unitID string, fromStepKey string, updatedAt time.Time) error { - if strings.TrimSpace(unitID) == "" { - return nil - } - var instances []model.WorkflowInstance - if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Find(&instances).Error; err != nil { - return err - } - for _, instance := range instances { - var steps []model.WorkflowStepInstance - if err := tx.Where("workflow_instance_id = ?", instance.ID).Order("position asc").Find(&steps).Error; err != nil { - return err - } - fromPosition := -1 - for _, step := range steps { - if step.StepKey == fromStepKey { - fromPosition = step.Position - break - } - } - if fromPosition < 0 { - continue - } - for _, step := range steps { - if step.Position < fromPosition { - continue - } - status := model.WorkflowStepStatusPending - if step.Position == fromPosition { - status = model.WorkflowStepStatusRunning - } - if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, instance.ID).Updates(map[string]any{ - "status": status, "error": "", "completed_at": nil, "updated_at": updatedAt, - }).Error; err != nil { - return err - } - } - if err := tx.Model(&model.WorkflowInstance{}).Where("id = ?", instance.ID).Updates(map[string]any{"status": model.WorkflowStatusActive, "revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error; err != nil { - return err - } - } - return nil -} - -func (r *Repository) ProjectShotAssetReferences(projectID string) ([]model.ShotAssetReference, error) { - var references []model.ShotAssetReference - err := r.db.Table("shot_asset_references").Select("shot_asset_references.*"). - Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). - Where("shots.project_id = ?", projectID). - Order("shot_asset_references.created_at asc").Scan(&references).Error - return references, err -} - -func (r *Repository) ProjectAssetCandidates(projectID string) ([]model.ProjectAssetCandidate, error) { - var candidates []model.ProjectAssetCandidate - err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&candidates).Error - return candidates, err -} - -func (r *Repository) ProjectAssetCandidate(projectID string, candidateID string) (*model.ProjectAssetCandidate, error) { - var candidate model.ProjectAssetCandidate - if err := r.db.First(&candidate, "id = ? AND project_id = ?", candidateID, projectID).Error; err != nil { - return nil, err - } - return &candidate, nil -} - -func (r *Repository) CreateProjectAssetCandidates(candidates []model.ProjectAssetCandidate) error { - if len(candidates) == 0 { - return nil - } - return r.db.Create(&candidates).Error -} - -func (r *Repository) CreateProjectAssetCandidate(candidate *model.ProjectAssetCandidate) (bool, error) { - result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(candidate) - return result.RowsAffected == 1, result.Error -} - -// ConfirmProjectAssetCandidate 将正式资产身份、首版本、项目引用和候选状态放在同一事务中,避免出现半确认数据。 -func (r *Repository) ConfirmProjectAssetCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink, createAsset bool) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if createAsset { - if err := tx.Create(asset).Error; err != nil { - return err - } - if err := tx.Create(version).Error; err != nil { - return err - } - } else if err := tx.First(&model.Asset{}, "id = ? AND user_id = ?", asset.ID, asset.UserID).Error; err != nil { - return err - } - if err := tx.Where("project_id = ? AND asset_id = ?", link.ProjectID, link.AssetID).FirstOrCreate(link).Error; err != nil { - return err - } - result := tx.Model(&model.ProjectAssetCandidate{}). - Where("id = ? AND project_id = ? AND status = ?", candidate.ID, candidate.ProjectID, "pending_confirmation"). - Updates(map[string]any{"status": candidate.Status, "resolved_asset_id": candidate.ResolvedAssetID, "updated_at": candidate.UpdatedAt}) - if result.Error != nil { - return result.Error - } - if result.RowsAffected != 1 { - return gorm.ErrInvalidData - } - return tx.Model(&model.Project{}).Where("id = ?", candidate.ProjectID). - Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": candidate.UpdatedAt}).Error - }) -} - -func (r *Repository) WorkflowTemplateVersion(templateKey string, version int) (*model.WorkflowTemplateVersion, error) { - var template model.WorkflowTemplateVersion - if err := r.db.First(&template, "template_key = ? AND version = ?", templateKey, version).Error; err != nil { - return nil, err - } - return &template, nil -} - -func (r *Repository) CreateWorkflowTemplateVersion(template *model.WorkflowTemplateVersion) error { - return r.db.Create(template).Error -} - -func (r *Repository) ProjectWorkflowInstances(projectID string) ([]model.WorkflowInstance, error) { - var instances []model.WorkflowInstance - err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&instances).Error - return instances, err -} - -func (r *Repository) WorkflowInstanceForScope(projectID string, unitID string, templateVersionID string) (*model.WorkflowInstance, error) { - var instance model.WorkflowInstance - if err := r.db.First(&instance, "project_id = ? AND unit_id = ? AND template_version_id = ?", projectID, unitID, templateVersionID).Error; err != nil { - return nil, err - } - return &instance, nil -} - -func (r *Repository) WorkflowInstance(id string) (*model.WorkflowInstance, error) { - var instance model.WorkflowInstance - if err := r.db.First(&instance, "id = ?", id).Error; err != nil { - return nil, err - } - return &instance, nil -} - -func (r *Repository) WorkflowSteps(instanceID string) ([]model.WorkflowStepInstance, error) { - var steps []model.WorkflowStepInstance - err := r.db.Where("workflow_instance_id = ?", instanceID).Order("position asc").Find(&steps).Error - return steps, err -} - -func (r *Repository) NextWorkflowStep(instanceID string, position int) (*model.WorkflowStepInstance, error) { - var step model.WorkflowStepInstance - if err := r.db.Where("workflow_instance_id = ? AND position > ?", instanceID, position).Order("position asc").First(&step).Error; err != nil { - return nil, err - } - return &step, nil -} - -func (r *Repository) CreateWorkflowInstance(instance *model.WorkflowInstance, steps []model.WorkflowStepInstance) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Create(instance).Error; err != nil { - return err - } - if len(steps) == 0 { - return nil - } - return tx.Create(&steps).Error - }) -} - -func (r *Repository) WorkflowStepForProject(projectID string, stepID string) (*model.WorkflowStepInstance, error) { - var step model.WorkflowStepInstance - err := r.db.Table("workflow_step_instances").Select("workflow_step_instances.*").Joins("JOIN workflow_instances ON workflow_instances.id = workflow_step_instances.workflow_instance_id").Where("workflow_instances.project_id = ? AND workflow_step_instances.id = ?", projectID, stepID).First(&step).Error - if err != nil { - return nil, err - } - return &step, nil -} - -func (r *Repository) UpdateWorkflowStep(step *model.WorkflowStepInstance) error { - return r.db.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{"status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt}).Error -} - -// UpdateWorkflowProgress 原子保存当前步骤、下一步骤和实例状态,确保刷新后流程依赖仍可恢复。 -func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{ - "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, - "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt, - }).Error; err != nil { - return err - } - if next != nil { - if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID). - Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt}).Error; err != nil { - return err - } - } - if err := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID). - Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt}).Error; err != nil { - return err - } - return tx.Model(&model.Project{}).Where("id = ?", projectID). - Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt}).Error - }) -} - -// RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。 -func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation, productionLink *model.ProductionTaskLink, artifact *model.ShotArtifact) error { - return r.db.Transaction(func(tx *gorm.DB) error { - var existingLink model.WorkflowStepTask - if err := tx.Where("workflow_step_id = ? AND task_id = ?", link.WorkflowStepID, link.TaskID).First(&existingLink).Error; errors.Is(err, gorm.ErrRecordNotFound) { - if err := tx.Create(link).Error; err != nil { - return err - } - } else if err != nil { - return err - } - if representation != nil { - var existingRepresentation model.AssetRepresentation - if err := tx.Where("task_id = ? AND role = ?", representation.TaskID, representation.Role).First(&existingRepresentation).Error; errors.Is(err, gorm.ErrRecordNotFound) { - if err := tx.Create(representation).Error; err != nil { - return err - } - } else if err != nil { - return err - } - } - if productionLink != nil { - var existingProductionLink model.ProductionTaskLink - err := tx.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", productionLink.TaskID, productionLink.ShotID, productionLink.ArtifactType).First(&existingProductionLink).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - if err := tx.Create(productionLink).Error; err != nil { - return err - } - } else if err != nil { - return err - } else if err := tx.Model(&existingProductionLink).Updates(map[string]any{ - "project_id": productionLink.ProjectID, "canvas_id": productionLink.CanvasID, "unit_id": productionLink.UnitID, - "workflow_step_id": productionLink.WorkflowStepID, "updated_at": productionLink.UpdatedAt, - }).Error; err != nil { - return err - } - } - if artifact != nil { - var existing model.ShotArtifact - if err := tx.Where("task_id = ? AND shot_id = ? AND type = ?", artifact.TaskID, artifact.ShotID, artifact.Type).First(&existing).Error; err == nil { - artifact = nil - } else if !errors.Is(err, gorm.ErrRecordNotFound) { - return err - } - } - if artifact != nil { - var currentVersion int - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { - return err - } - artifact.Version = currentVersion + 1 - if artifact.Selected { - if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil { - return err - } - } - if err := tx.Create(artifact).Error; err != nil { - return err - } - } - stepResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{ - "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, - "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt, - }) - if stepResult.Error != nil { - return stepResult.Error - } - if stepResult.RowsAffected != 1 { - return gorm.ErrInvalidData - } - if next != nil { - nextResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID).Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt}) - if nextResult.Error != nil { - return nextResult.Error - } - if nextResult.RowsAffected != 1 { - return gorm.ErrInvalidData - } - } - instanceResult := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID).Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt}) - if instanceResult.Error != nil { - return instanceResult.Error - } - if instanceResult.RowsAffected != 1 { - return gorm.ErrInvalidData - } - projectResult := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt}) - if projectResult.Error != nil { - return projectResult.Error - } - if projectResult.RowsAffected != 1 { - return gorm.ErrInvalidData - } - return nil - }) -} - -func (r *Repository) CanvasShareForProject(userID string, projectID string) (*model.CanvasShare, error) { - var share model.CanvasShare - if err := r.db.First(&share, "user_id = ? AND project_id = ?", userID, projectID).Error; err != nil { - return nil, err - } - return &share, nil -} - -func (r *Repository) CanvasShareByTokenHash(tokenHash string) (*model.CanvasShare, error) { - var share model.CanvasShare - if err := r.db.First(&share, "token_hash = ? AND enabled = ?", tokenHash, true).Error; err != nil { - return nil, err - } - return &share, nil -} - -func (r *Repository) DeleteCanvasShare(userID string, projectID string) error { - return r.db.Delete(&model.CanvasShare{}, "user_id = ? AND project_id = ?", userID, projectID).Error -} - -func (r *Repository) PromptTemplates() ([]model.PromptTemplate, error) { - var templates []model.PromptTemplate - err := r.db.Order("operation asc, version desc").Find(&templates).Error - return templates, err -} - -func (r *Repository) PromptTemplate(id string) (*model.PromptTemplate, error) { - var template model.PromptTemplate - if err := r.db.First(&template, "id = ?", id).Error; err != nil { - return nil, err - } - return &template, nil -} - -func (r *Repository) ActivePromptTemplate(operation string) (*model.PromptTemplate, error) { - var template model.PromptTemplate - if err := r.db.Order("version desc").First(&template, "operation = ? AND enabled = ?", operation, true).Error; err != nil { - return nil, err - } - return &template, nil -} - -func (r *Repository) PromptTemplateCount(operation string) (int64, error) { - var count int64 - err := r.db.Model(&model.PromptTemplate{}).Where("operation = ?", operation).Count(&count).Error - return count, err -} - -func (r *Repository) NextPromptTemplateVersion(operation string) (int, error) { - var version int - err := r.db.Model(&model.PromptTemplate{}).Where("operation = ?", operation).Select("COALESCE(MAX(version), 0)").Scan(&version).Error - return version + 1, err -} - -func (r *Repository) SavePromptTemplate(template *model.PromptTemplate) error { - return r.db.Transaction(func(tx *gorm.DB) error { - if template.Enabled { - if err := tx.Model(&model.PromptTemplate{}).Where("operation = ? AND id <> ?", template.Operation, template.ID).Update("enabled", false).Error; err != nil { - return err - } - } - return tx.Save(template).Error - }) -} - -func (r *Repository) DeletePromptTemplate(id string) error { - return r.db.Delete(&model.PromptTemplate{}, "id = ?", id).Error -} - -func (r *Repository) UserPromptCustomizations(userID string) ([]model.UserPromptCustomization, error) { - var customizations []model.UserPromptCustomization - err := r.db.Where("user_id = ?", userID).Order("operation asc").Find(&customizations).Error - return customizations, err -} - -func (r *Repository) UserPromptCustomization(userID string, operation string) (*model.UserPromptCustomization, error) { - var customization model.UserPromptCustomization - if err := r.db.First(&customization, "user_id = ? AND operation = ?", userID, operation).Error; err != nil { - return nil, err - } - return &customization, nil -} - -func (r *Repository) SaveUserPromptCustomization(customization *model.UserPromptCustomization) error { - return r.db.Save(customization).Error -} - -func (r *Repository) DeleteUserPromptCustomization(userID string, operation string) error { - return r.db.Delete(&model.UserPromptCustomization{}, "user_id = ? AND operation = ?", userID, operation).Error -} diff --git a/backend/internal/repository/repository_assets_canvases.go b/backend/internal/repository/repository_assets_canvases.go new file mode 100644 index 000000000..44abe118a --- /dev/null +++ b/backend/internal/repository/repository_assets_canvases.go @@ -0,0 +1,160 @@ +// 素材与画布项目的基础读写。 +// +// 素材的彻底删除(含引用、Outbox)在 resource_reference.go 的 DeleteAssetsAndResources。 + +package repository + +import ( + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) Assets(userID string) ([]model.Asset, error) { + var assets []model.Asset + err := r.db.Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error + return assets, err +} + +func (r *Repository) AssetSummaries(userID string) ([]model.Asset, error) { + var assets []model.Asset + err := r.db.Select("id", "folder_id", "kind", "category", "status", "primary_version_id", "title", "created_at", "updated_at").Order("updated_at desc").Find(&assets, "user_id = ?", userID).Error + return assets, err +} + +func (r *Repository) AssetForUser(userID string, id string) (*model.Asset, error) { + var asset model.Asset + if err := r.db.First(&asset, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &asset, nil +} + +func (r *Repository) AssetsForUserIDs(userID string, ids []string) ([]model.Asset, error) { + if len(ids) == 0 { + return nil, nil + } + var assets []model.Asset + err := r.db.Find(&assets, "user_id = ? AND id IN ?", userID, ids).Error + return assets, err +} + +func (r *Repository) UpsertAsset(asset *model.Asset) error { + result := r.db.Model(&model.Asset{}). + Where("id = ? AND user_id = ?", asset.ID, asset.UserID). + Updates(map[string]any{"folder_id": asset.FolderID, "kind": asset.Kind, "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "title": asset.Title, "payload_json": asset.PayloadJSON, "updated_at": asset.UpdatedAt}) + if result.Error != nil || result.RowsAffected > 0 { + return result.Error + } + return r.db.Create(asset).Error +} + +func (r *Repository) DeleteAsset(userID string, id string) error { + return r.DeleteAssetAndResources(userID, id, nil, nil, false) +} + +func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) { + var assets []model.Asset + if limit <= 0 { + limit = 100 + } + err := r.db.Where("status = ? AND updated_at <= ?", model.AssetVersionStatusArchived, cutoff). + Order("updated_at asc, id asc"). + Limit(limit). + Find(&assets).Error + return assets, err +} + +func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Delete(&model.Asset{}, "user_id = ?", userID).Error; err != nil { + return err + } + if len(assets) == 0 { + return nil + } + return tx.Create(&assets).Error + }) +} + +func (r *Repository) CanvasProjects(userID string) ([]model.CanvasProject, error) { + var projects []model.CanvasProject + err := r.db.Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error + return projects, err +} + +func (r *Repository) CanvasProjectSummaries(userID string) ([]model.CanvasProject, error) { + var projects []model.CanvasProject + err := r.db.Select("id", "title", "revision", "created_at", "updated_at").Order("updated_at desc").Find(&projects, "user_id = ?", userID).Error + return projects, err +} + +func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.CanvasProject, error) { + var project model.CanvasProject + if err := r.db.First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &project, nil +} + +func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error { + expected := project.Revision + if expected < 0 { + return ErrCanvasRevisionConflict + } + if expected == 0 { + created := *project + created.Revision = 1 + result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&created) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrCanvasRevisionConflict + } + project.Revision = 1 + return nil + } + // The revision predicate and increment must be in the same SQL statement. + // A missing row is a conflict, never an invitation to recreate a deleted canvas. + result := r.db.Model(&model.CanvasProject{}). + Where("id = ? AND user_id = ? AND revision = ?", project.ID, project.UserID, expected). + Updates(map[string]any{"project_id": project.ProjectID, "title": project.Title, "payload_json": project.PayloadJSON, "updated_at": project.UpdatedAt, "revision": expected + 1}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrCanvasRevisionConflict + } + project.Revision = expected + 1 + return nil +} + +func (r *Repository) DeleteCanvasProject(userID string, id string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // Serialize deletion with saves before reading the history IDs to remove. + if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND id = ?", userID, id).UpdateColumn("revision", gorm.Expr("revision")).Error; err != nil { + return err + } + var snapshotIDs []string + if err := tx.Model(&model.CanvasSnapshot{}).Where("user_id = ? AND canvas_id = ?", userID, id).Pluck("id", &snapshotIDs).Error; err != nil { + return err + } + if err := deleteCanvasSnapshots(tx, snapshotIDs); err != nil { + return err + } + if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil { + return err + } + if err := tx.Where("canvas_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil { + return err + } + // 任务和会话是审计记录,不随独立画布实体保留归属 ID,避免删除后继续挂住画布上下文。 + if err := tx.Model(&model.Task{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { + return err + } + return tx.Delete(&model.CanvasProject{}, "id = ? AND user_id = ?", id, userID).Error + }) +} diff --git a/backend/internal/repository/repository_channels_settings.go b/backend/internal/repository/repository_channels_settings.go new file mode 100644 index 000000000..5dde6a22c --- /dev/null +++ b/backend/internal/repository/repository_channels_settings.go @@ -0,0 +1,171 @@ +// 系统渠道、系统设置与方舟私有素材绑定。 + +package repository + +import ( + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) SystemChannels(includeDisabled bool) ([]model.ModelChannel, error) { + var channels []model.ModelChannel + query := r.db.Order("sort_order asc, created_at asc, id asc").Where("scope = ?", model.ChannelScopeSystem) + if !includeDisabled { + query = query.Where("enabled = ?", true) + } + err := query.Find(&channels).Error + return channels, err +} + +func (r *Repository) HistoricalSystemChannelReferences() ([]model.ModelChannel, error) { + var channels []model.ModelChannel + err := r.db.Unscoped().Select("id", "name").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error + return channels, err +} + +func (r *Repository) AdminSystemChannels(keyword string, status string, limit int, offset int) ([]model.ModelChannel, int64, error) { + var channels []model.ModelChannel + var total int64 + query := r.db.Model(&model.ModelChannel{}).Where("scope = ?", model.ChannelScopeSystem) + if value := strings.TrimSpace(keyword); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Where("lower(name) LIKE ? OR lower(base_url) LIKE ?", pattern, pattern) + } + if status == "enabled" { + query = query.Where("enabled = ?", true) + } else if status == "disabled" { + query = query.Where("enabled = ?", false) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("sort_order asc, created_at asc, id asc").Limit(limit).Offset(offset).Find(&channels).Error; err != nil { + return nil, 0, err + } + return channels, total, nil +} + +func (r *Repository) AdminSystemChannelReferences() ([]model.ModelChannel, error) { + var channels []model.ModelChannel + err := r.db.Select("id", "name", "enabled").Where("scope = ?", model.ChannelScopeSystem).Order("created_at asc").Find(&channels).Error + return channels, err +} + +func (r *Repository) SystemChannel(id string) (*model.ModelChannel, error) { + var channel model.ModelChannel + if err := r.db.First(&channel, "id = ? AND scope = ? AND enabled = ?", id, model.ChannelScopeSystem, true).Error; err != nil { + return nil, err + } + return &channel, nil +} + +func (r *Repository) AdminSystemChannel(id string) (*model.ModelChannel, error) { + var channel model.ModelChannel + if err := r.db.First(&channel, "id = ? AND scope = ?", id, model.ChannelScopeSystem).Error; err != nil { + return nil, err + } + return &channel, nil +} + +func (r *Repository) DeleteSystemChannel(id string) error { + now := time.Now() + return r.db.Transaction(func(tx *gorm.DB) error { + channelResult := tx.Model(&model.ModelChannel{}). + Where("id = ? AND scope = ?", id, model.ChannelScopeSystem). + Updates(map[string]any{"api_key": "", "secret_key": "", "enabled": false, "updated_at": now}) + if channelResult.Error != nil { + return channelResult.Error + } + if channelResult.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + if err := tx.Model(&model.ChannelModel{}).Where("channel_id = ?", id).Updates(map[string]any{"enabled": false, "updated_at": now}).Error; err != nil { + return err + } + if err := tx.Where("channel_id = ?", id).Delete(&model.ChannelModel{}).Error; err != nil { + return err + } + return tx.Where("id = ? AND scope = ?", id, model.ChannelScopeSystem).Delete(&model.ModelChannel{}).Error + }) +} + +func (r *Repository) ApiCallLogs(userID string, admin bool, limit int) ([]model.ApiCallLog, error) { + var logs []model.ApiCallLog + if limit <= 0 || limit > 200 { + limit = 100 + } + query := r.db.Order("created_at desc").Limit(limit) + if !admin { + query = query.Where("user_id = ?", userID) + } + err := query.Omit("RequestBody", "ResponseBody").Find(&logs).Error + return logs, err +} + +func (r *Repository) SystemSetting(key string) (*model.SystemSetting, error) { + var setting model.SystemSetting + if err := r.db.First(&setting, "key = ?", key).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func (r *Repository) SystemSettingOptional(key string) (*model.SystemSetting, error) { + var setting model.SystemSetting + if err := r.db.Where("key = ?", key).Limit(1).Find(&setting).Error; err != nil { + return nil, err + } + if setting.Key == "" { + return nil, nil + } + return &setting, nil +} + +func (r *Repository) SaveSystemSetting(setting *model.SystemSetting) error { + return r.db.Save(setting).Error +} + +func (r *Repository) SaveSystemSettings(settings ...*model.SystemSetting) error { + return r.db.Transaction(func(tx *gorm.DB) error { + for _, setting := range settings { + if err := tx.Save(setting).Error; err != nil { + return err + } + } + return nil + }) +} + +func (r *Repository) ArkPrivateAssetBinding(resourceID string, projectName string) (*model.ArkPrivateAssetBinding, error) { + var binding model.ArkPrivateAssetBinding + if err := r.db.Where("resource_id = ? AND project_name = ?", resourceID, projectName).First(&binding).Error; err != nil { + return nil, err + } + return &binding, nil +} + +// CreateArkPrivateAssetBinding establishes a single uploader for a resource +// and Ark Project. Other workers can wait for that binding instead of +// importing the same image repeatedly. +func (r *Repository) CreateArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) (bool, error) { + result := r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "resource_id"}, {Name: "project_name"}}, + DoNothing: true, + }).Create(binding) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected == 1, nil +} + +func (r *Repository) SaveArkPrivateAssetBinding(binding *model.ArkPrivateAssetBinding) error { + return r.db.Save(binding).Error +} + +func (r *Repository) DeleteSystemSetting(key string) error { + return r.db.Delete(&model.SystemSetting{}, "key = ?", key).Error +} diff --git a/backend/internal/repository/repository_projects.go b/backend/internal/repository/repository_projects.go new file mode 100644 index 000000000..7f93b4310 --- /dev/null +++ b/backend/internal/repository/repository_projects.go @@ -0,0 +1,575 @@ +// 短剧项目、分集单元、项目素材库与画布-单元关联。 + +package repository + +import ( + "fmt" + "sort" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) Projects(userID string) ([]model.Project, error) { + var projects []model.Project + err := r.db.Where("user_id = ?", userID).Order("updated_at desc").Find(&projects).Error + return projects, err +} + +func (r *Repository) ProjectsPage(userID string, page int, pageSize int) ([]model.Project, int64, error) { + var projects []model.Project + var total int64 + query := r.db.Model(&model.Project{}).Where("user_id = ?", userID) + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("updated_at desc").Offset((page - 1) * pageSize).Limit(pageSize).Find(&projects).Error; err != nil { + return nil, 0, err + } + return projects, total, nil +} + +func (r *Repository) ProjectForUser(userID string, id string) (*model.Project, error) { + var project model.Project + if err := r.db.First(&project, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &project, nil +} + +func (r *Repository) CreateProject(project *model.Project) error { + return r.db.Create(project).Error +} + +func (r *Repository) UpdateProject(project *model.Project) error { + return r.db.Model(&model.Project{}).Where("id = ? AND user_id = ?", project.ID, project.UserID).Updates(map[string]any{ + "name": project.Name, "type": project.Type, "aspect_ratio": project.AspectRatio, "source_type": project.SourceType, + "description": project.Description, "cover_resource_id": project.CoverResourceID, + "style_preset_id": project.StylePresetID, "style_profile_json": project.StyleProfileJSON, + "default_image_model": project.DefaultImageModel, "default_video_model": project.DefaultVideoModel, + "status": project.Status, "revision": project.Revision, "updated_at": project.UpdatedAt, + }).Error +} + +func (r *Repository) DeleteProject(userID string, id string, canvasUpdates []model.CanvasProject) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var canvasIDs []string + if err := tx.Model(&model.CanvasProject{}). + Where("user_id = ? AND project_id = ?", userID, id). + Pluck("id", &canvasIDs).Error; err != nil { + return err + } + projectScopeIDs := append([]string{id}, canvasIDs...) + var activeTaskCount int64 + if err := tx.Model(&model.Task{}). + Where("user_id = ? AND project_id IN ? AND status IN ?", userID, projectScopeIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&activeTaskCount).Error; err != nil { + return err + } + if activeTaskCount > 0 { + return ErrProjectHasActiveTasks + } + if err := tx.Where("user_id = ? AND project_id = ?", userID, id).Delete(&model.CanvasShare{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.CanvasUnitLink{}).Error; err != nil { + return err + } + for _, canvas := range canvasUpdates { + result := tx.Model(&model.CanvasProject{}). + Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvas.ID, userID, id, canvas.Revision). + Updates(map[string]any{"project_id": "", "payload_json": canvas.PayloadJSON, "updated_at": canvas.UpdatedAt, "revision": canvas.Revision + 1}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + } + var remainingCanvasCount int64 + if err := tx.Model(&model.CanvasProject{}).Where("user_id = ? AND project_id = ?", userID, id).Count(&remainingCanvasCount).Error; err != nil { + return err + } + if remainingCanvasCount != 0 { + return fmt.Errorf("项目删除时仍有 %d 个画布关联未处理", remainingCanvasCount) + } + shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ?", id) + if err := tx.Where("project_id = ?", id).Delete(&model.ProductionTaskLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.Shot{}).Error; err != nil { + return err + } + instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ?", id) + stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs) + if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil { + return err + } + if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.WorkflowInstance{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetFolder{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ?", id).Delete(&model.ProjectUnit{}).Error; err != nil { + return err + } + if err := tx.Model(&model.Task{}).Where("user_id = ? AND project_id = ?", userID, id).Update("project_id", "").Error; err != nil { + return err + } + return tx.Delete(&model.Project{}, "id = ? AND user_id = ?", id, userID).Error + }) +} + +func (r *Repository) BumpProjectRevision(projectID string) error { + return r.db.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error +} + +func (r *Repository) ProjectUnits(projectID string) ([]model.ProjectUnit, error) { + var units []model.ProjectUnit + err := r.db.Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error + return units, err +} + +func (r *Repository) ProjectUnitSummaries(projectID string) ([]model.ProjectUnit, error) { + var units []model.ProjectUnit + err := r.db.Select("id", "project_id", "kind", "title", "word_count", "status", "position", "created_at", "updated_at").Where("project_id = ?", projectID).Order("position asc, created_at asc").Find(&units).Error + return units, err +} + +func (r *Repository) ProjectAssetCount(projectID string) (int64, error) { + var count int64 + err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ?", projectID).Count(&count).Error + return count, err +} + +func (r *Repository) CreateProjectUnit(unit *model.ProjectUnit) error { + return r.db.Create(unit).Error +} + +func (r *Repository) ImportProjectUnits(projectID string, units []model.ProjectUnit) error { + return r.db.Transaction(func(tx *gorm.DB) error { + // 分批写入仍处于同一事务,避免两千章导入超过 SQLite/PostgreSQL 单语句参数上限。 + if err := tx.CreateInBatches(&units, 100).Error; err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +func (r *Repository) ReorderProjectUnits(projectID string, unitIDs []string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + now := time.Now() + for position, unitID := range unitIDs { + result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unitID, projectID).Updates(map[string]any{"position": position, "updated_at": now}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": now}).Error + }) +} + +func (r *Repository) ProjectUnit(projectID string, id string) (*model.ProjectUnit, error) { + var unit model.ProjectUnit + if err := r.db.First(&unit, "id = ? AND project_id = ?", id, projectID).Error; err != nil { + return nil, err + } + return &unit, nil +} + +func (r *Repository) UpdateProjectUnit(unit *model.ProjectUnit, invalidateWorkflow bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ProjectUnit{}).Where("id = ? AND project_id = ?", unit.ID, unit.ProjectID).Updates(map[string]any{ + "parent_id": unit.ParentID, "title": unit.Title, "source_text": unit.SourceText, "word_count": unit.WordCount, "status": unit.Status, "position": unit.Position, "updated_at": unit.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + if invalidateWorkflow { + if err := tx.Model(&model.ShotArtifact{}).Where("project_id = ? AND unit_id = ? AND status NOT IN ?", unit.ProjectID, unit.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": unit.UpdatedAt}).Error; err != nil { + return err + } + if err := invalidateUnitWorkflowTx(tx, unit.ProjectID, unit.ID, "story", unit.UpdatedAt); err != nil { + return err + } + } + return tx.Model(&model.Project{}).Where("id = ?", unit.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": unit.UpdatedAt}).Error + }) +} + +func (r *Repository) DeleteProjectUnit(projectID string, id string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.CanvasUnitLink{}).Error; err != nil { + return err + } + shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id) + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProductionTaskLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.Shot{}).Error; err != nil { + return err + } + instanceIDs := tx.Model(&model.WorkflowInstance{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, id) + stepIDs := tx.Model(&model.WorkflowStepInstance{}).Select("id").Where("workflow_instance_id IN (?)", instanceIDs) + if err := tx.Where("workflow_step_id IN (?)", stepIDs).Delete(&model.WorkflowStepTask{}).Error; err != nil { + return err + } + if err := tx.Where("workflow_instance_id IN (?)", instanceIDs).Delete(&model.WorkflowStepInstance{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.WorkflowInstance{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, id).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + return err + } + result := tx.Delete(&model.ProjectUnit{}, "id = ? AND project_id = ?", id, projectID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +func (r *Repository) CanvasUnitLink(projectID string, canvasID string, unitID string) (*model.CanvasUnitLink, error) { + var link model.CanvasUnitLink + if err := r.db.First(&link, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID).Error; err != nil { + return nil, err + } + return &link, nil +} + +func (r *Repository) UpsertCanvasUnitLink(link *model.CanvasUnitLink) error { + result := r.db.Model(&model.CanvasUnitLink{}).Where("project_id = ? AND canvas_id = ? AND unit_id = ?", link.ProjectID, link.CanvasID, link.UnitID).Updates(map[string]any{"role": link.Role}) + if result.Error != nil || result.RowsAffected > 0 { + return result.Error + } + return r.db.Create(link).Error +} + +func (r *Repository) ProjectCanvasSummaries(userID string, projectID string) ([]model.CanvasProject, error) { + var canvases []model.CanvasProject + err := r.db.Select("id", "user_id", "project_id", "title", "revision", "created_at", "updated_at").Where("user_id = ? AND project_id = ?", userID, projectID).Order("updated_at desc").Find(&canvases).Error + return canvases, err +} + +func (r *Repository) ProjectCanvasDocuments(userID string, projectID string) ([]model.CanvasProject, error) { + var canvases []model.CanvasProject + err := r.db.Select("id", "title", "payload_json", "revision").Where("user_id = ? AND project_id = ?", userID, projectID).Find(&canvases).Error + return canvases, err +} + +func (r *Repository) ProjectCanvasUnitLinks(projectID string) ([]model.CanvasUnitLink, error) { + var links []model.CanvasUnitLink + err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&links).Error + return links, err +} + +func (r *Repository) DeleteCanvasUnitLink(projectID string, canvasID string, unitID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Delete(&model.CanvasUnitLink{}, "project_id = ? AND canvas_id = ? AND unit_id = ?", projectID, canvasID, unitID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +func (r *Repository) AssignCanvasToProject(userID string, canvasID string, projectID string) error { + return r.db.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ?", canvasID, userID).Updates(map[string]any{"project_id": projectID, "revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error +} + +func (r *Repository) UnassignCanvasFromProject(userID string, projectID string, canvasID string, payloadJSON string, updatedAt time.Time, revision int64) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("project_id = ? AND canvas_id = ?", projectID, canvasID).Delete(&model.CanvasUnitLink{}).Error; err != nil { + return err + } + result := tx.Model(&model.CanvasProject{}).Where("id = ? AND user_id = ? AND project_id = ? AND revision = ?", canvasID, userID, projectID, revision).Updates(map[string]any{ + "project_id": "", "payload_json": payloadJSON, "updated_at": updatedAt, "revision": revision + 1, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) +} + +func (r *Repository) ProjectAssets(userID string, projectID string) ([]model.Asset, error) { + var assets []model.Asset + err := r.db.Table("assets").Select("assets.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = assets.id").Where("assets.user_id = ? AND project_asset_links.project_id = ?", userID, projectID).Order("assets.updated_at desc").Scan(&assets).Error + return assets, err +} + +func (r *Repository) ProjectAssetLinks(projectID string) ([]model.ProjectAssetLink, error) { + var links []model.ProjectAssetLink + err := r.db.Where("project_id = ?", projectID).Order("folder_id asc, position asc, created_at asc").Find(&links).Error + return links, err +} + +func (r *Repository) ProjectAssetLink(projectID string, assetID string) (*model.ProjectAssetLink, error) { + var link model.ProjectAssetLink + if err := r.db.First(&link, "project_id = ? AND asset_id = ?", projectID, assetID).Error; err != nil { + return nil, err + } + return &link, nil +} + +func (r *Repository) NextProjectAssetPosition(projectID string, folderID string) (int, error) { + var result struct{ Maximum int } + err := r.db.Model(&model.ProjectAssetLink{}). + Select("COALESCE(MAX(position), -1) AS maximum"). + Where("project_id = ? AND folder_id = ?", projectID, folderID). + Scan(&result).Error + return result.Maximum + 1, err +} + +func (r *Repository) MoveProjectAsset(projectID string, assetID string, folderID string, position int) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ProjectAssetLink{}). + Where("project_id = ? AND asset_id = ?", projectID, assetID). + Updates(map[string]any{"folder_id": folderID, "position": position}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +func (r *Repository) ProjectAssetFolders(projectID string) ([]model.ProjectAssetFolder, error) { + var folders []model.ProjectAssetFolder + err := r.db.Where("project_id = ?", projectID).Order("parent_id asc, position asc, created_at asc").Find(&folders).Error + return folders, err +} + +func (r *Repository) ProjectAssetFolder(projectID string, folderID string) (*model.ProjectAssetFolder, error) { + var folder model.ProjectAssetFolder + if err := r.db.First(&folder, "id = ? AND project_id = ?", folderID, projectID).Error; err != nil { + return nil, err + } + return &folder, nil +} + +func (r *Repository) CreateProjectAssetFolder(folder *model.ProjectAssetFolder) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(folder).Error; err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error + }) +} + +func (r *Repository) UpdateProjectAssetFolder(folder *model.ProjectAssetFolder) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ProjectAssetFolder{}). + Where("id = ? AND project_id = ?", folder.ID, folder.ProjectID). + Updates(map[string]any{"parent_id": folder.ParentID, "name": folder.Name, "name_key": folder.NameKey, "style": folder.Style, "theme": folder.Theme, "position": folder.Position, "updated_at": folder.UpdatedAt}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", folder.ProjectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": folder.UpdatedAt}).Error + }) +} + +func (r *Repository) DeleteProjectAssetFolder(projectID string, folderID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var childCount int64 + if err := tx.Model(&model.ProjectAssetFolder{}).Where("project_id = ? AND parent_id = ?", projectID, folderID).Count(&childCount).Error; err != nil { + return err + } + var assetCount int64 + if err := tx.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND folder_id = ?", projectID, folderID).Count(&assetCount).Error; err != nil { + return err + } + if childCount > 0 || assetCount > 0 { + return ErrProjectAssetFolderNotEmpty + } + result := tx.Delete(&model.ProjectAssetFolder{}, "id = ? AND project_id = ?", folderID, projectID) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", projectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +// LinkProjectAsset 将首版本、素材领域字段、项目引用和修订号原子提交,避免产生半关联资产。 +// 资产首次入库也在此事务内完成(service 层只做内存构造,不预落库), +// 事务失败时资产一并回滚,不再留下“有资产无链接”的孤儿资产。 +func (r *Repository) LinkProjectAsset(asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink) (bool, error) { + createdLink := false + err := r.db.Transaction(func(tx *gorm.DB) error { + // 资产可能尚未落库(首次导入)或已存在(并发/重试),冲突幂等跳过。 + assetCreated := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(asset) + if assetCreated.Error != nil { + return assetCreated.Error + } + created := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "project_id"}, {Name: "asset_id"}}, DoNothing: true}).Create(link) + if created.Error != nil { + return created.Error + } + if created.RowsAffected == 0 { + return nil + } + createdLink = true + if version != nil { + if err := tx.Create(version).Error; err != nil { + return err + } + } + result := tx.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{ + "category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return tx.Model(&model.Project{}).Where("id = ?", link.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) + return createdLink, err +} + +func (r *Repository) DeleteProjectAssetLink(projectID string, assetID string) error { + return r.db.Delete(&model.ProjectAssetLink{}, "project_id = ? AND asset_id = ?", projectID, assetID).Error +} + +func (r *Repository) ProjectAssetShotReferenceCount(projectID string, assetID string) (int64, error) { + var count int64 + err := r.db.Table("shot_asset_references"). + Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). + Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id"). + Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID). + Count(&count).Error + return count, err +} + +func (r *Repository) ProjectAssetLinked(projectID string, assetID string) (bool, error) { + var count int64 + err := r.db.Model(&model.ProjectAssetLink{}).Where("project_id = ? AND asset_id = ?", projectID, assetID).Count(&count).Error + return count > 0, err +} + +func (r *Repository) AssetReferenceCount(assetID string) (int64, error) { + var projectLinks int64 + if err := r.db.Model(&model.ProjectAssetLink{}).Where("asset_id = ?", assetID).Count(&projectLinks).Error; err != nil { + return 0, err + } + var shotLinks int64 + err := r.db.Table("shot_asset_references").Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id").Where("asset_versions.asset_id = ?", assetID).Count(&shotLinks).Error + return projectLinks + shotLinks, err +} + +func (r *Repository) UpdateAssetDomain(asset *model.Asset) error { + return r.db.Model(&model.Asset{}).Where("id = ? AND user_id = ?", asset.ID, asset.UserID).Updates(map[string]any{"category": asset.Category, "status": asset.Status, "primary_version_id": asset.PrimaryVersionID, "updated_at": asset.UpdatedAt}).Error +} + +func (r *Repository) AssetVersions(assetID string) ([]model.AssetVersion, error) { + var versions []model.AssetVersion + err := r.db.Where("asset_id = ?", assetID).Order("version desc").Find(&versions).Error + return versions, err +} + +func (r *Repository) ProjectAssetUsageRoles(projectID string, assetID string) ([]string, error) { + var shotRoles []string + if err := r.db.Table("shot_asset_references"). + Distinct("shot_asset_references.role"). + Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). + Joins("JOIN asset_versions ON asset_versions.id = shot_asset_references.asset_version_id"). + Where("shots.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID). + Order("shot_asset_references.role asc"). + Pluck("shot_asset_references.role", &shotRoles).Error; err != nil { + return nil, err + } + var representationRoles []string + if err := r.db.Table("asset_representations"). + Distinct("asset_representations.role"). + Joins("JOIN asset_versions ON asset_versions.id = asset_representations.asset_version_id"). + Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id"). + Where("project_asset_links.project_id = ? AND asset_versions.asset_id = ?", projectID, assetID). + Pluck("asset_representations.role", &representationRoles).Error; err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(shotRoles)+len(representationRoles)) + for _, role := range append(shotRoles, representationRoles...) { + if role != "" { + seen[role] = struct{}{} + } + } + roles := make([]string, 0, len(seen)) + for role := range seen { + roles = append(roles, role) + } + sort.Strings(roles) + return roles, nil +} + +func (r *Repository) AssetVersionForProject(projectID string, versionID string) (*model.AssetVersion, error) { + var version model.AssetVersion + err := r.db.Table("asset_versions").Select("asset_versions.*").Joins("JOIN project_asset_links ON project_asset_links.asset_id = asset_versions.asset_id").Where("project_asset_links.project_id = ? AND asset_versions.id = ?", projectID, versionID).First(&version).Error + if err != nil { + return nil, err + } + return &version, nil +} + +func (r *Repository) CreateAssetVersion(version *model.AssetVersion) error { + return r.db.Create(version).Error +} diff --git a/backend/internal/repository/repository_prompts_shares.go b/backend/internal/repository/repository_prompts_shares.go new file mode 100644 index 000000000..8b88aa172 --- /dev/null +++ b/backend/internal/repository/repository_prompts_shares.go @@ -0,0 +1,99 @@ +// 画布分享与提示词模板。 + +package repository + +import ( + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) CanvasShareForProject(userID string, projectID string) (*model.CanvasShare, error) { + var share model.CanvasShare + if err := r.db.First(&share, "user_id = ? AND project_id = ?", userID, projectID).Error; err != nil { + return nil, err + } + return &share, nil +} + +func (r *Repository) CanvasShareByTokenHash(tokenHash string) (*model.CanvasShare, error) { + var share model.CanvasShare + if err := r.db.First(&share, "token_hash = ? AND enabled = ?", tokenHash, true).Error; err != nil { + return nil, err + } + return &share, nil +} + +func (r *Repository) DeleteCanvasShare(userID string, projectID string) error { + return r.db.Delete(&model.CanvasShare{}, "user_id = ? AND project_id = ?", userID, projectID).Error +} + +func (r *Repository) PromptTemplates() ([]model.PromptTemplate, error) { + var templates []model.PromptTemplate + err := r.db.Order("operation asc, version desc").Find(&templates).Error + return templates, err +} + +func (r *Repository) PromptTemplate(id string) (*model.PromptTemplate, error) { + var template model.PromptTemplate + if err := r.db.First(&template, "id = ?", id).Error; err != nil { + return nil, err + } + return &template, nil +} + +func (r *Repository) ActivePromptTemplate(operation string) (*model.PromptTemplate, error) { + var template model.PromptTemplate + if err := r.db.Order("version desc").First(&template, "operation = ? AND enabled = ?", operation, true).Error; err != nil { + return nil, err + } + return &template, nil +} + +func (r *Repository) PromptTemplateCount(operation string) (int64, error) { + var count int64 + err := r.db.Model(&model.PromptTemplate{}).Where("operation = ?", operation).Count(&count).Error + return count, err +} + +func (r *Repository) NextPromptTemplateVersion(operation string) (int, error) { + var version int + err := r.db.Model(&model.PromptTemplate{}).Where("operation = ?", operation).Select("COALESCE(MAX(version), 0)").Scan(&version).Error + return version + 1, err +} + +func (r *Repository) SavePromptTemplate(template *model.PromptTemplate) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if template.Enabled { + if err := tx.Model(&model.PromptTemplate{}).Where("operation = ? AND id <> ?", template.Operation, template.ID).Update("enabled", false).Error; err != nil { + return err + } + } + return tx.Save(template).Error + }) +} + +func (r *Repository) DeletePromptTemplate(id string) error { + return r.db.Delete(&model.PromptTemplate{}, "id = ?", id).Error +} + +func (r *Repository) UserPromptCustomizations(userID string) ([]model.UserPromptCustomization, error) { + var customizations []model.UserPromptCustomization + err := r.db.Where("user_id = ?", userID).Order("operation asc").Find(&customizations).Error + return customizations, err +} + +func (r *Repository) UserPromptCustomization(userID string, operation string) (*model.UserPromptCustomization, error) { + var customization model.UserPromptCustomization + if err := r.db.First(&customization, "user_id = ? AND operation = ?", userID, operation).Error; err != nil { + return nil, err + } + return &customization, nil +} + +func (r *Repository) SaveUserPromptCustomization(customization *model.UserPromptCustomization) error { + return r.db.Save(customization).Error +} + +func (r *Repository) DeleteUserPromptCustomization(userID string, operation string) error { + return r.db.Delete(&model.UserPromptCustomization{}, "user_id = ? AND operation = ?", userID, operation).Error +} diff --git a/backend/internal/repository/repository_shots_workflows.go b/backend/internal/repository/repository_shots_workflows.go new file mode 100644 index 000000000..aa2b19c6a --- /dev/null +++ b/backend/internal/repository/repository_shots_workflows.go @@ -0,0 +1,597 @@ +// 镜头、镜头产物、素材引用与项目工作流。 +// +// 镜头与素材引用的变更会使所在单元的工作流步骤失效(invalidateUnitWorkflowTx), +// 保证下游步骤不会基于过期镜头继续生成。 + +package repository + +import ( + "errors" + "slices" + "sort" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) ProjectShots(projectID string) ([]model.Shot, error) { + var shots []model.Shot + err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, position asc").Find(&shots).Error + return shots, err +} + +func (r *Repository) SaveShot(shot *model.Shot, create bool) error { + if create { + return r.db.Create(shot).Error + } + return r.db.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ + "unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position, + "duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt, + }).Error +} + +// SaveShotWithRevision 原子保存镜头当前值、新版本和下游失效状态。 +func (r *Repository) SaveShotWithRevision(shot *model.Shot, revision *model.ShotRevision, create bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if create { + if err := tx.Create(shot).Error; err != nil { + return err + } + } else { + result := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ + "unit_id": shot.UnitID, "title": shot.Title, "description": shot.Description, "position": shot.Position, + "duration_ms": shot.DurationMs, "status": shot.Status, "updated_at": shot.UpdatedAt, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + } + var currentVersion int + if err := tx.Model(&model.ShotRevision{}).Where("shot_id = ?", shot.ID).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { + return err + } + revision.Version = currentVersion + 1 + if err := tx.Create(revision).Error; err != nil { + return err + } + shot.CurrentRevisionID = revision.ID + if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", shot.ID, shot.ProjectID).Updates(map[string]any{ + "current_revision_id": revision.ID, "description": shot.Description, "duration_ms": shot.DurationMs, + "status": shot.Status, "updated_at": shot.UpdatedAt, + }).Error; err != nil { + return err + } + if !create { + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shot.ID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": shot.UpdatedAt}).Error; err != nil { + return err + } + } + if err := invalidateUnitWorkflowTx(tx, shot.ProjectID, shot.UnitID, "storyboard", shot.UpdatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", shot.ProjectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": shot.UpdatedAt}).Error + }) +} + +func (r *Repository) ReplaceProjectUnitShots(projectID string, unitID string, shots []model.Shot, revisions []model.ShotRevision, references []model.ShotAssetReference, expectedShotIDs []string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if expectedShotIDs != nil { + var currentShotIDs []string + if err := tx.Model(&model.Shot{}).Where("project_id = ? AND unit_id = ?", projectID, unitID).Order("id asc").Pluck("id", ¤tShotIDs).Error; err != nil { + return err + } + expected := append([]string(nil), expectedShotIDs...) + sort.Strings(expected) + if !slices.Equal(currentShotIDs, expected) { + return ErrProjectUnitShotsChanged + } + } + shotIDs := tx.Model(&model.Shot{}).Select("id").Where("project_id = ? AND unit_id = ?", projectID, unitID) + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id IN (?)", shotIDs).Delete(&model.ShotAssetReference{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id IN (?)", projectID, shotIDs).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Delete(&model.Shot{}).Error; err != nil { + return err + } + if err := tx.Create(&shots).Error; err != nil { + return err + } + if len(revisions) > 0 { + if err := tx.Create(&revisions).Error; err != nil { + return err + } + } + if len(references) > 0 { + if err := tx.Create(&references).Error; err != nil { + return err + } + } + if err := invalidateUnitWorkflowTx(tx, projectID, unitID, "storyboard", time.Now()); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": time.Now()}).Error + }) +} + +func (r *Repository) ShotForProject(projectID string, shotID string) (*model.Shot, error) { + var shot model.Shot + if err := r.db.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { + return nil, err + } + return &shot, nil +} + +func (r *Repository) ShotRevisionForShot(shotID string, revisionID string) (*model.ShotRevision, error) { + var revision model.ShotRevision + if err := r.db.First(&revision, "id = ? AND shot_id = ?", revisionID, shotID).Error; err != nil { + return nil, err + } + return &revision, nil +} + +// DeleteProjectShot 原子删除单个镜头的领域关联,并重新压紧同章节镜头顺序。 +func (r *Repository) DeleteProjectShot(projectID string, shotID string, updatedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var shot model.Shot + if err := tx.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ProductionTaskLink{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ShotArtifact{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id = ?", shotID).Delete(&model.ShotRevision{}).Error; err != nil { + return err + } + if err := tx.Where("shot_id = ?", shotID).Delete(&model.ShotAssetReference{}).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND shot_id = ?", projectID, shotID).Delete(&model.ProjectAssetCandidate{}).Error; err != nil { + return err + } + result := tx.Where("id = ? AND project_id = ?", shotID, projectID).Delete(&model.Shot{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + var remaining []model.Shot + if err := tx.Select("id", "position").Where("project_id = ? AND unit_id = ?", projectID, shot.UnitID).Order("position asc, created_at asc, id asc").Find(&remaining).Error; err != nil { + return err + } + for position, item := range remaining { + if item.Position == position { + continue + } + if err := tx.Model(&model.Shot{}).Where("id = ? AND project_id = ?", item.ID, projectID).Update("position", position).Error; err != nil { + return err + } + } + if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) +} + +func (r *Repository) ProjectShotRevisions(projectID string) ([]model.ShotRevision, error) { + var revisions []model.ShotRevision + err := r.db.Table("shot_revisions").Select("shot_revisions.*"). + Joins("JOIN shots ON shots.id = shot_revisions.shot_id"). + Where("shots.project_id = ?", projectID). + Order("shots.unit_id asc, shots.position asc, shot_revisions.version asc").Scan(&revisions).Error + return revisions, err +} + +func (r *Repository) ProjectShotArtifacts(projectID string) ([]model.ShotArtifact, error) { + var artifacts []model.ShotArtifact + err := r.db.Where("project_id = ?", projectID).Order("unit_id asc, shot_id asc, type asc, version asc").Find(&artifacts).Error + return artifacts, err +} + +func (r *Repository) CreateShotArtifact(artifact *model.ShotArtifact) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var currentVersion int + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { + return err + } + artifact.Version = currentVersion + 1 + if artifact.Selected { + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil { + return err + } + } + return tx.Create(artifact).Error + }) +} + +func (r *Repository) MarkShotArtifactsStale(shotID string, updatedAt time.Time) error { + return r.db.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error +} + +func (r *Repository) UpsertProductionTaskLink(link *model.ProductionTaskLink) error { + return r.db.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", link.TaskID, link.ShotID, link.ArtifactType).Assign(map[string]any{ + "project_id": link.ProjectID, "canvas_id": link.CanvasID, "unit_id": link.UnitID, "shot_id": link.ShotID, + "workflow_step_id": link.WorkflowStepID, "artifact_type": link.ArtifactType, "updated_at": link.UpdatedAt, + }).FirstOrCreate(link).Error +} + +func (r *Repository) UpsertShotAssetReference(reference *model.ShotAssetReference) error { + result := r.db.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status}) + if result.Error != nil || result.RowsAffected > 0 { + return result.Error + } + return r.db.Create(reference).Error +} + +func (r *Repository) UpsertShotAssetReferenceAndInvalidate(projectID string, reference *model.ShotAssetReference, updatedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.ShotAssetReference{}).Where("shot_id = ? AND asset_version_id = ? AND role = ?", reference.ShotID, reference.AssetVersionID, reference.Role).Updates(map[string]any{"status": reference.Status}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + if err := tx.Create(reference).Error; err != nil { + return err + } + } + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", reference.ShotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil { + return err + } + var shot model.Shot + if err := tx.First(&shot, "id = ? AND project_id = ?", reference.ShotID, projectID).Error; err != nil { + return err + } + if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) +} + +func (r *Repository) DeleteShotAssetReferenceAndInvalidate(projectID string, shotID string, referenceID string, updatedAt time.Time) (bool, error) { + deleted := false + err := r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Where("id = ? AND shot_id = ?", referenceID, shotID).Delete(&model.ShotAssetReference{}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + deleted = true + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND status NOT IN ?", shotID, []string{"failed", "stale"}).Updates(map[string]any{"status": "stale", "selected": false, "updated_at": updatedAt}).Error; err != nil { + return err + } + var shot model.Shot + if err := tx.First(&shot, "id = ? AND project_id = ?", shotID, projectID).Error; err != nil { + return err + } + if err := invalidateUnitWorkflowTx(tx, projectID, shot.UnitID, "storyboard", updatedAt); err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error + }) + return deleted, err +} + +func invalidateUnitWorkflowTx(tx *gorm.DB, projectID string, unitID string, fromStepKey string, updatedAt time.Time) error { + if strings.TrimSpace(unitID) == "" { + return nil + } + var instances []model.WorkflowInstance + if err := tx.Where("project_id = ? AND unit_id = ?", projectID, unitID).Find(&instances).Error; err != nil { + return err + } + for _, instance := range instances { + var steps []model.WorkflowStepInstance + if err := tx.Where("workflow_instance_id = ?", instance.ID).Order("position asc").Find(&steps).Error; err != nil { + return err + } + fromPosition := -1 + for _, step := range steps { + if step.StepKey == fromStepKey { + fromPosition = step.Position + break + } + } + if fromPosition < 0 { + continue + } + for _, step := range steps { + if step.Position < fromPosition { + continue + } + status := model.WorkflowStepStatusPending + if step.Position == fromPosition { + status = model.WorkflowStepStatusRunning + } + if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, instance.ID).Updates(map[string]any{ + "status": status, "error": "", "completed_at": nil, "updated_at": updatedAt, + }).Error; err != nil { + return err + } + } + if err := tx.Model(&model.WorkflowInstance{}).Where("id = ?", instance.ID).Updates(map[string]any{"status": model.WorkflowStatusActive, "revision": gorm.Expr("revision + 1"), "updated_at": updatedAt}).Error; err != nil { + return err + } + } + return nil +} + +func (r *Repository) ProjectShotAssetReferences(projectID string) ([]model.ShotAssetReference, error) { + var references []model.ShotAssetReference + err := r.db.Table("shot_asset_references").Select("shot_asset_references.*"). + Joins("JOIN shots ON shots.id = shot_asset_references.shot_id"). + Where("shots.project_id = ?", projectID). + Order("shot_asset_references.created_at asc").Scan(&references).Error + return references, err +} + +func (r *Repository) ProjectAssetCandidates(projectID string) ([]model.ProjectAssetCandidate, error) { + var candidates []model.ProjectAssetCandidate + err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&candidates).Error + return candidates, err +} + +func (r *Repository) ProjectAssetCandidate(projectID string, candidateID string) (*model.ProjectAssetCandidate, error) { + var candidate model.ProjectAssetCandidate + if err := r.db.First(&candidate, "id = ? AND project_id = ?", candidateID, projectID).Error; err != nil { + return nil, err + } + return &candidate, nil +} + +func (r *Repository) CreateProjectAssetCandidates(candidates []model.ProjectAssetCandidate) error { + if len(candidates) == 0 { + return nil + } + return r.db.Create(&candidates).Error +} + +func (r *Repository) CreateProjectAssetCandidate(candidate *model.ProjectAssetCandidate) (bool, error) { + result := r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(candidate) + return result.RowsAffected == 1, result.Error +} + +// ConfirmProjectAssetCandidate 将正式资产身份、首版本、项目引用和候选状态放在同一事务中,避免出现半确认数据。 +func (r *Repository) ConfirmProjectAssetCandidate(candidate *model.ProjectAssetCandidate, asset *model.Asset, version *model.AssetVersion, link *model.ProjectAssetLink, createAsset bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if createAsset { + if err := tx.Create(asset).Error; err != nil { + return err + } + if err := tx.Create(version).Error; err != nil { + return err + } + } else if err := tx.First(&model.Asset{}, "id = ? AND user_id = ?", asset.ID, asset.UserID).Error; err != nil { + return err + } + if err := tx.Where("project_id = ? AND asset_id = ?", link.ProjectID, link.AssetID).FirstOrCreate(link).Error; err != nil { + return err + } + result := tx.Model(&model.ProjectAssetCandidate{}). + Where("id = ? AND project_id = ? AND status = ?", candidate.ID, candidate.ProjectID, "pending_confirmation"). + Updates(map[string]any{"status": candidate.Status, "resolved_asset_id": candidate.ResolvedAssetID, "updated_at": candidate.UpdatedAt}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrInvalidData + } + return tx.Model(&model.Project{}).Where("id = ?", candidate.ProjectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": candidate.UpdatedAt}).Error + }) +} + +func (r *Repository) WorkflowTemplateVersion(templateKey string, version int) (*model.WorkflowTemplateVersion, error) { + var template model.WorkflowTemplateVersion + if err := r.db.First(&template, "template_key = ? AND version = ?", templateKey, version).Error; err != nil { + return nil, err + } + return &template, nil +} + +func (r *Repository) CreateWorkflowTemplateVersion(template *model.WorkflowTemplateVersion) error { + return r.db.Create(template).Error +} + +func (r *Repository) ProjectWorkflowInstances(projectID string) ([]model.WorkflowInstance, error) { + var instances []model.WorkflowInstance + err := r.db.Where("project_id = ?", projectID).Order("created_at asc").Find(&instances).Error + return instances, err +} + +func (r *Repository) WorkflowInstanceForScope(projectID string, unitID string, templateVersionID string) (*model.WorkflowInstance, error) { + var instance model.WorkflowInstance + if err := r.db.First(&instance, "project_id = ? AND unit_id = ? AND template_version_id = ?", projectID, unitID, templateVersionID).Error; err != nil { + return nil, err + } + return &instance, nil +} + +func (r *Repository) WorkflowInstance(id string) (*model.WorkflowInstance, error) { + var instance model.WorkflowInstance + if err := r.db.First(&instance, "id = ?", id).Error; err != nil { + return nil, err + } + return &instance, nil +} + +func (r *Repository) WorkflowSteps(instanceID string) ([]model.WorkflowStepInstance, error) { + var steps []model.WorkflowStepInstance + err := r.db.Where("workflow_instance_id = ?", instanceID).Order("position asc").Find(&steps).Error + return steps, err +} + +func (r *Repository) NextWorkflowStep(instanceID string, position int) (*model.WorkflowStepInstance, error) { + var step model.WorkflowStepInstance + if err := r.db.Where("workflow_instance_id = ? AND position > ?", instanceID, position).Order("position asc").First(&step).Error; err != nil { + return nil, err + } + return &step, nil +} + +func (r *Repository) CreateWorkflowInstance(instance *model.WorkflowInstance, steps []model.WorkflowStepInstance) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Create(instance).Error; err != nil { + return err + } + if len(steps) == 0 { + return nil + } + return tx.Create(&steps).Error + }) +} + +func (r *Repository) WorkflowStepForProject(projectID string, stepID string) (*model.WorkflowStepInstance, error) { + var step model.WorkflowStepInstance + err := r.db.Table("workflow_step_instances").Select("workflow_step_instances.*").Joins("JOIN workflow_instances ON workflow_instances.id = workflow_step_instances.workflow_instance_id").Where("workflow_instances.project_id = ? AND workflow_step_instances.id = ?", projectID, stepID).First(&step).Error + if err != nil { + return nil, err + } + return &step, nil +} + +func (r *Repository) UpdateWorkflowStep(step *model.WorkflowStepInstance) error { + return r.db.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{"status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt}).Error +} + +// UpdateWorkflowProgress 原子保存当前步骤、下一步骤和实例状态,确保刷新后流程依赖仍可恢复。 +func (r *Repository) UpdateWorkflowProgress(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{ + "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, + "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt, + }).Error; err != nil { + return err + } + if next != nil { + if err := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID). + Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt}).Error; err != nil { + return err + } + } + if err := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID). + Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt}).Error; err != nil { + return err + } + return tx.Model(&model.Project{}).Where("id = ?", projectID). + Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt}).Error + }) +} + +// RegisterWorkflowTaskOutput 将成功任务、流程步骤和产物表示写入同一事务,重复回填使用任务与用途唯一键幂等。 +func (r *Repository) RegisterWorkflowTaskOutput(step *model.WorkflowStepInstance, next *model.WorkflowStepInstance, instance *model.WorkflowInstance, projectID string, link *model.WorkflowStepTask, representation *model.AssetRepresentation, productionLink *model.ProductionTaskLink, artifact *model.ShotArtifact) error { + return r.db.Transaction(func(tx *gorm.DB) error { + var existingLink model.WorkflowStepTask + if err := tx.Where("workflow_step_id = ? AND task_id = ?", link.WorkflowStepID, link.TaskID).First(&existingLink).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(link).Error; err != nil { + return err + } + } else if err != nil { + return err + } + if representation != nil { + var existingRepresentation model.AssetRepresentation + if err := tx.Where("task_id = ? AND role = ?", representation.TaskID, representation.Role).First(&existingRepresentation).Error; errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(representation).Error; err != nil { + return err + } + } else if err != nil { + return err + } + } + if productionLink != nil { + var existingProductionLink model.ProductionTaskLink + err := tx.Where("task_id = ? AND shot_id = ? AND artifact_type = ?", productionLink.TaskID, productionLink.ShotID, productionLink.ArtifactType).First(&existingProductionLink).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := tx.Create(productionLink).Error; err != nil { + return err + } + } else if err != nil { + return err + } else if err := tx.Model(&existingProductionLink).Updates(map[string]any{ + "project_id": productionLink.ProjectID, "canvas_id": productionLink.CanvasID, "unit_id": productionLink.UnitID, + "workflow_step_id": productionLink.WorkflowStepID, "updated_at": productionLink.UpdatedAt, + }).Error; err != nil { + return err + } + } + if artifact != nil { + var existing model.ShotArtifact + if err := tx.Where("task_id = ? AND shot_id = ? AND type = ?", artifact.TaskID, artifact.ShotID, artifact.Type).First(&existing).Error; err == nil { + artifact = nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + } + if artifact != nil { + var currentVersion int + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Select("COALESCE(MAX(version), 0)").Scan(¤tVersion).Error; err != nil { + return err + } + artifact.Version = currentVersion + 1 + if artifact.Selected { + if err := tx.Model(&model.ShotArtifact{}).Where("shot_id = ? AND type = ?", artifact.ShotID, artifact.Type).Updates(map[string]any{"selected": false, "updated_at": artifact.UpdatedAt}).Error; err != nil { + return err + } + } + if err := tx.Create(artifact).Error; err != nil { + return err + } + } + stepResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", step.ID, step.WorkflowInstanceID).Updates(map[string]any{ + "status": step.Status, "output_json": step.OutputJSON, "error": step.Error, "started_at": step.StartedAt, + "completed_at": step.CompletedAt, "updated_at": step.UpdatedAt, + }) + if stepResult.Error != nil { + return stepResult.Error + } + if stepResult.RowsAffected != 1 { + return gorm.ErrInvalidData + } + if next != nil { + nextResult := tx.Model(&model.WorkflowStepInstance{}).Where("id = ? AND workflow_instance_id = ?", next.ID, next.WorkflowInstanceID).Updates(map[string]any{"status": next.Status, "updated_at": next.UpdatedAt}) + if nextResult.Error != nil { + return nextResult.Error + } + if nextResult.RowsAffected != 1 { + return gorm.ErrInvalidData + } + } + instanceResult := tx.Model(&model.WorkflowInstance{}).Where("id = ? AND project_id = ?", instance.ID, projectID).Updates(map[string]any{"status": instance.Status, "revision": instance.Revision, "updated_at": instance.UpdatedAt}) + if instanceResult.Error != nil { + return instanceResult.Error + } + if instanceResult.RowsAffected != 1 { + return gorm.ErrInvalidData + } + projectResult := tx.Model(&model.Project{}).Where("id = ?", projectID).Updates(map[string]any{"revision": gorm.Expr("revision + 1"), "updated_at": step.UpdatedAt}) + if projectResult.Error != nil { + return projectResult.Error + } + if projectResult.RowsAffected != 1 { + return gorm.ErrInvalidData + } + return nil + }) +} diff --git a/backend/internal/repository/repository_storage_resources.go b/backend/internal/repository/repository_storage_resources.go new file mode 100644 index 000000000..d1b293cdc --- /dev/null +++ b/backend/internal/repository/repository_storage_resources.go @@ -0,0 +1,268 @@ +// 对象存储配置、存储位置、上传配额与资源(Resource)记录。 +// +// 资源的物理删除不在这里做:业务删除写入 resource_deletion_jobs(Outbox), +// 由后台 worker 幂等清理,见 resource_deletion.go。 + +package repository + +import ( + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) LatestUserOSSSetting(userID string) (*model.UserOSSSetting, error) { + var setting model.UserOSSSetting + if err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").First(&setting).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func (r *Repository) UserOSSSettingsForUser(userID string) ([]model.UserOSSSetting, error) { + var settings []model.UserOSSSetting + err := r.db.Where("user_id = ?", userID).Order("created_at desc, id desc").Find(&settings).Error + return settings, err +} + +func (r *Repository) UserOSSSettingForUser(userID string, id string) (*model.UserOSSSetting, error) { + var setting model.UserOSSSetting + if err := r.db.First(&setting, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &setting, nil +} + +func (r *Repository) CreateUserOSSSetting(setting *model.UserOSSSetting) error { + return r.db.Create(setting).Error +} + +func (r *Repository) StorageLocation(id string) (*model.StorageLocation, error) { + var location model.StorageLocation + if err := r.db.First(&location, "id = ?", id).Error; err != nil { + return nil, err + } + return &location, nil +} + +func (r *Repository) StorageLocationByDigest(scope string, ownerID string, provider string, digest string) (*model.StorageLocation, error) { + var location model.StorageLocation + if err := r.db.First(&location, "scope = ? AND owner_id = ? AND provider = ? AND location_digest = ?", scope, ownerID, provider, digest).Error; err != nil { + return nil, err + } + return &location, nil +} + +func (r *Repository) StorageLocationHistoryCount(scope string, ownerID string) (int64, error) { + var count int64 + err := r.db.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ?", scope, ownerID).Count(&count).Error + return count, err +} + +func (r *Repository) StorageLocationResourceCount(id string) (int64, error) { + var count int64 + err := r.db.Model(&model.Resource{}).Where("storage_setting_id = ?", id).Count(&count).Error + return count, err +} + +func (r *Repository) CreateStorageLocation(location *model.StorageLocation) error { + return r.db.Create(location).Error +} + +func (r *Repository) SaveStorageLocation(location *model.StorageLocation) error { + return r.db.Save(location).Error +} + +func (r *Repository) ActivateStorageLocation(scope string, ownerID string, id string, active bool) error { + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.StorageLocation{}).Where("scope = ? AND owner_id = ? AND active = ?", scope, ownerID, true).Update("active", false).Error; err != nil { + return err + } + if !active { + return nil + } + result := tx.Model(&model.StorageLocation{}).Where("id = ? AND scope = ? AND owner_id = ?", id, scope, ownerID).Update("active", true) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (r *Repository) ReserveDailyUpload(userID string, day string, size int64, limit int64) error { + usage := model.UserDailyUploadUsage{ID: userID + ":" + day, UserID: userID, Day: day} + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&usage).Error; err != nil { + return err + } + result := tx.Model(&model.UserDailyUploadUsage{}). + Where("id = ? AND bytes + ? < ?", usage.ID, size, limit). + Updates(map[string]any{"bytes": gorm.Expr("bytes + ?", size), "updated_at": time.Now()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrDailyUploadLimitExceeded + } + return nil + }) +} + +func (r *Repository) ReleaseDailyUpload(userID string, day string, size int64) error { + id := userID + ":" + day + return r.db.Model(&model.UserDailyUploadUsage{}). + Where("id = ?", id). + Updates(map[string]any{ + "bytes": gorm.Expr("CASE WHEN bytes >= ? THEN bytes - ? ELSE 0 END", size, size), + "updated_at": time.Now(), + }).Error +} + +func (r *Repository) UserStoredFileBytes(userID string) (int64, error) { + var total int64 + err := r.db.Raw(` + SELECT + COALESCE(( + SELECT SUM(physical_resources.size) + FROM ( + SELECT MAX(size) AS size + FROM resources + WHERE user_id = ? AND status = ? + GROUP BY COALESCE(NULLIF(provider, ''), 'local'), endpoint, bucket, object_key + ) AS physical_resources + ), 0) + `, userID, model.ResourceStatusReady).Scan(&total).Error + return total, err +} + +func (r *Repository) DailyUploadBytes(userID string, day string) (int64, error) { + var total int64 + err := r.db.Model(&model.UserDailyUploadUsage{}).Select("COALESCE(bytes, 0)").Where("user_id = ? AND day = ?", userID, day).Scan(&total).Error + return total, err +} + +func (r *Repository) CreateResource(resource *model.Resource) error { + return r.db.Create(resource).Error +} + +func (r *Repository) SaveResource(resource *model.Resource) error { + return r.db.Save(resource).Error +} + +// UpdateResourceThumbnail only updates a still-ready row and cannot resurrect a deleted resource. +func (r *Repository) UpdateResourceThumbnail(userID string, id string, values map[string]any) (bool, error) { + result := r.db.Model(&model.Resource{}). + Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusReady). + Updates(values) + return result.RowsAffected == 1, result.Error +} + +func (r *Repository) ResourceByUploadKey(userID string, uploadKey string) (*model.Resource, error) { + var resource model.Resource + if err := r.db.First(&resource, "user_id = ? AND upload_key = ?", userID, uploadKey).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (r *Repository) ClaimFailedResourceUpload(userID string, id string) (bool, error) { + result := r.db.Model(&model.Resource{}). + Where("id = ? AND user_id = ? AND status = ?", id, userID, model.ResourceStatusFailed). + Updates(map[string]any{"status": model.ResourceStatusPending, "error": "", "updated_at": time.Now()}) + return result.RowsAffected == 1, result.Error +} + +func (r *Repository) DeleteResource(userID string, id string) error { + return r.db.Delete(&model.Resource{}, "id = ? AND user_id = ?", id, userID).Error +} + +func (r *Repository) Resource(id string) (*model.Resource, error) { + var resource model.Resource + if err := r.db.First(&resource, "id = ?", id).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (r *Repository) ResourceForUser(userID string, id string) (*model.Resource, error) { + var resource model.Resource + if err := r.db.First(&resource, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (r *Repository) Resources(userID string, limit int) ([]model.Resource, error) { + var resources []model.Resource + if limit <= 0 || limit > 500 { + limit = 200 + } + err := r.db.Order("created_at desc").Limit(limit).Find(&resources, "user_id = ?", userID).Error + return resources, err +} + +// PlaybackPendingVideos 返回本地存储、就绪但尚无播放副本判定结果的视频 +// (H.264 需标记 none、H.265 需触发转码)。 +func (r *Repository) PlaybackPendingVideos(limit int) ([]model.Resource, error) { + var resources []model.Resource + if limit <= 0 || limit > 100 { + limit = 20 + } + err := r.db.Where("kind = ? AND status = ? AND provider = ? AND (playback_status = ? OR playback_status IS NULL)", + "video", model.ResourceStatusReady, "local", "").Order("created_at asc").Limit(limit).Find(&resources).Error + return resources, err +} + +// PlaybackNoneVideos 返回存量本地视频中旧逻辑遗留、停在 none 的行 +// (规则变更前 H.265/MPEG-4 Part 2 曾被误判为浏览器可播并落 none)。 +// 服务启动回填时对它们重新按 codec 判定,让判定规则变更覆盖规则变更前已导入的文件。 +func (r *Repository) PlaybackNoneVideos(limit int) ([]model.Resource, error) { + var resources []model.Resource + if limit <= 0 || limit > 100 { + limit = 20 + } + err := r.db.Where("kind = ? AND status = ? AND provider = ? AND playback_status = ?", + "video", model.ResourceStatusReady, "local", model.PlaybackStatusNone). + Order("created_at asc").Limit(limit).Find(&resources).Error + return resources, err +} + +// ClaimPlaybackTranscode 原子地把待判定(空/none)视频置为 processing,返回是否抢占成功。 +// 多实例或多 goroutine 并发转同一资源时仅一个能成功置位,其余返回 false 直接放弃, +// 避免重复转码同一份文件。 +func (r *Repository) ClaimPlaybackTranscode(id string) (bool, error) { + res := r.db.Model(&model.Resource{}). + Where("id = ? AND (playback_status = ? OR playback_status IS NULL OR playback_status = ?)", + id, "", model.PlaybackStatusNone). + Updates(map[string]any{"playback_status": model.PlaybackStatusProcessing, "playback_error": ""}) + if res.Error != nil { + return false, res.Error + } + return res.RowsAffected > 0, nil +} + +// ResetStuckPlaybackTranscodes 服务重启时把卡在 processing 的转码记录重置回待判定 +// (进程崩溃后转码 goroutine 随进程消亡,状态永远停在 processing)。 +func (r *Repository) ResetStuckPlaybackTranscodes() error { + return r.db.Model(&model.Resource{}). + Where("playback_status = ?", model.PlaybackStatusProcessing). + Updates(map[string]any{"playback_status": "", "playback_error": ""}).Error +} + +func (r *Repository) ResourceCleanupCandidates(incompleteBefore time.Time, readyBefore time.Time, limit int) ([]model.Resource, error) { + var resources []model.Resource + if limit <= 0 || limit > 500 { + limit = 100 + } + err := r.db.Where( + "(status IN ? AND updated_at <= ?) OR (status = ? AND created_at <= ?)", + []model.ResourceStatus{model.ResourceStatusPending, model.ResourceStatusFailed}, incompleteBefore, + model.ResourceStatusReady, readyBefore, + ).Order("created_at asc, id asc").Limit(limit).Find(&resources).Error + return resources, err +} diff --git a/backend/internal/repository/repository_tasks.go b/backend/internal/repository/repository_tasks.go new file mode 100644 index 000000000..27e5cb540 --- /dev/null +++ b/backend/internal/repository/repository_tasks.go @@ -0,0 +1,403 @@ +// 生成任务的持久化:领取、租约续期、Provider 进度与终态写回。 +// +// 所有「执行中」写入都带租约校验(taskLeaseWriter):进程被新执行者接管后,旧执行者的 +// 迟到写入会因租约不匹配被丢弃,避免覆盖新结果或重复扣费。 + +package repository + +import ( + "encoding/json" + "errors" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) Task(id string) (*model.Task, error) { + var task model.Task + if err := r.db.First(&task, "id = ?", id).Error; err != nil { + return nil, err + } + return &task, nil +} + +func (r *Repository) TaskForUser(userID string, id string) (*model.Task, error) { + var task model.Task + if err := r.db.First(&task, "id = ? AND user_id = ?", id, userID).Error; err != nil { + return nil, err + } + return &task, nil +} + +func (r *Repository) ActiveTaskCountForUser(userID string) (int64, error) { + var count int64 + err := r.db.Model(&model.Task{}).Where("user_id = ? AND status IN ?", userID, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}).Count(&count).Error + return count, err +} + +func (r *Repository) ActiveTaskCountForProjectIDs(userID string, projectIDs []string) (int64, error) { + if len(projectIDs) == 0 { + return 0, nil + } + var count int64 + err := r.db.Model(&model.Task{}). + Where("user_id = ? AND project_id IN ? AND status IN ?", userID, projectIDs, []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}). + Count(&count).Error + return count, err +} + +// 任务领取以数据库租约为真相;PostgreSQL 锁行跳过竞争任务,SQLite 继续依赖条件更新保证单实例原子性。 +func (r *Repository) ClaimNextTask(owner string, leaseDuration time.Duration) (*model.Task, error) { + var task model.Task + now := time.Now() + leaseExpiresAt := now.Add(leaseDuration) + err := r.db.Transaction(func(tx *gorm.DB) error { + query := tx.Where("(status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))) AND (next_poll_at IS NULL OR next_poll_at <= ?)", model.TaskStatusQueued, model.TaskStatusRunning, now, now). + Order("created_at asc").Limit(1) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}) + } + result := query.Find(&task) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + task = model.Task{} + return nil + } + claim := tx.Model(&model.Task{}).Where("id = ?", task.ID) + if r.Dialect() != "postgres" { + claim = claim.Where("(status = ? OR (status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?))) AND (next_poll_at IS NULL OR next_poll_at <= ?)", model.TaskStatusQueued, model.TaskStatusRunning, now, now) + } + updated := claim. + Updates(map[string]any{ + "status": model.TaskStatusRunning, + "stage": "后端接管任务", + "progress": 15, + "attempts": gorm.Expr("attempts + ?", 1), + "started_at": gorm.Expr("COALESCE(started_at, ?)", now), + "lease_owner": owner, + "lease_expires_at": leaseExpiresAt, + "next_poll_at": nil, + "updated_at": now, + }) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected == 0 { + task = model.Task{} + return nil + } + return tx.First(&task, "id = ?", task.ID).Error + }) + if err != nil || task.ID == "" { + return nil, err + } + return &task, nil +} + +func (r *Repository) RenewTaskLease(id string, owner string, leaseDuration time.Duration) error { + result := r.db.Model(&model.Task{}). + Where("id = ? AND status = ? AND lease_owner = ? AND lease_expires_at > ?", id, model.TaskStatusRunning, owner, time.Now()). + Updates(map[string]any{"lease_expires_at": time.Now().Add(leaseDuration), "updated_at": time.Now()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New("任务租约已失效") + } + return nil +} + +func (r *Repository) UpdateTaskProviderState(id string, providerRequestID string, pollStage string, nextPollAt *time.Time) error { + updates := map[string]any{"poll_stage": pollStage, "next_poll_at": nextPollAt, "updated_at": time.Now()} + if strings.TrimSpace(providerRequestID) != "" { + updates["provider_request_id"] = strings.TrimSpace(providerRequestID) + } + return r.db.Model(&model.Task{}).Where("id = ?", id).Updates(updates).Error +} + +func (r *Repository) DeferRunningTaskForProviderPoll(id string, owner string, stage string, delay time.Duration) error { + now := time.Now() + result := taskLeaseWriter(r.db.Model(&model.Task{}), owner). + Where("id = ? AND status = ?", id, model.TaskStatusRunning). + Updates(map[string]any{ + "stage": stage, "error": "", "completed_at": nil, "next_poll_at": now.Add(delay), + "lease_owner": "", "lease_expires_at": nil, "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrTaskStateConflict + } + return nil +} + +// 人工恢复仅锁定失败任务;旧 worker 的租约可覆盖,但未过期的人工恢复租约不能并发抢占。 +func (r *Repository) ClaimFailedTaskProviderRecovery(id string, userID string, owner string, leaseDuration time.Duration) error { + now := time.Now() + query := r.db.Model(&model.Task{}).Where( + "id = ? AND status = ? AND (lease_owner = '' OR lease_owner NOT LIKE ? OR lease_expires_at IS NULL OR lease_expires_at <= ?)", + id, model.TaskStatusFailed, "manual-recovery:%", now, + ) + if strings.TrimSpace(userID) != "" { + query = query.Where("user_id = ?", userID) + } + result := query.Updates(map[string]any{ + "lease_owner": owner, + "lease_expires_at": now.Add(leaseDuration), + "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrTaskProviderRecoveryConflict + } + return nil +} + +func (r *Repository) ReleaseTaskProviderRecovery(id string, owner string) error { + return r.db.Model(&model.Task{}). + Where("id = ? AND lease_owner = ?", id, owner). + Updates(map[string]any{"lease_owner": "", "lease_expires_at": nil, "updated_at": time.Now()}).Error +} + +func (r *Repository) UpdateTaskProgress(id string, stage string, progress int) error { + return r.db.Model(&model.Task{}).Where("id = ? AND status = ?", id, model.TaskStatusRunning).Updates(map[string]any{ + "stage": stage, "progress": progress, "updated_at": time.Now(), + }).Error +} + +func (r *Repository) UpdateTaskProgressForLease(id string, owner string, stage string, progress int) error { + result := taskLeaseWriter(r.db.Model(&model.Task{}), owner). + Where("id = ? AND status = ?", id, model.TaskStatusRunning). + Updates(map[string]any{"stage": stage, "progress": progress, "updated_at": time.Now()}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrTaskStateConflict + } + return nil +} + +// 无租约任务仅能写无租约记录;有租约的执行者必须仍持有本次领取的有效 owner。 +func taskLeaseWriter(db *gorm.DB, owner string) *gorm.DB { + if owner == "" { + return db.Where("(lease_owner = '' OR lease_owner IS NULL)") + } + return db.Where("lease_owner = ? AND lease_expires_at > ?", owner, time.Now()) +} + +// UpdateTaskProviderProgress records upstream-reported progress without allowing +// a delayed or out-of-order poll response to move the public percentage backwards. +func (r *Repository) UpdateTaskProviderProgress(id string, progress int) error { + progress = max(0, min(100, progress)) + return r.db.Model(&model.Task{}).Where("id = ? AND status = ?", id, model.TaskStatusRunning).Updates(map[string]any{ + "stage": "上游生成中", + "progress": gorm.Expr("CASE WHEN progress < ? THEN ? ELSE progress END", progress, progress), + "updated_at": time.Now(), + }).Error +} + +func (r *Repository) SaveTaskCompletion(task *model.Task, expected model.TaskStatus, results []model.Result) error { + return r.SaveTaskCompletionWithRegistration(task, expected, results, nil) +} + +// Registration and terminal success commit together; a failed asset write leaves +// the task recoverable and never exposes a false success to the canvas. +func (r *Repository) SaveTaskCompletionWithRegistration(task *model.Task, expected model.TaskStatus, results []model.Result, register func(*Repository) error) error { + return r.db.Transaction(func(tx *gorm.DB) error { + updated := taskLeaseWriter(tx.Model(&model.Task{}), task.LeaseOwner). + Where("id = ? AND status = ?", task.ID, expected). + Select("*").Omit("id", "created_at").Updates(task) + if updated.Error != nil { + return updated.Error + } + if updated.RowsAffected != 1 { + return ErrTaskStateConflict + } + for index := range results { + if err := tx.Create(&results[index]).Error; err != nil { + return err + } + } + if register != nil { + return register(New(tx)) + } + return nil + }) +} + +func (r *Repository) UpdateTaskTerminalState(id string, owner string, expected model.TaskStatus, status model.TaskStatus, stage string, errorText string, completedAt time.Time) (bool, error) { + result := taskLeaseWriter(r.db.Model(&model.Task{}), owner). + Where("id = ? AND status = ?", id, expected). + Updates(map[string]any{ + "status": status, "stage": stage, "error": errorText, "completed_at": &completedAt, + "lease_owner": "", "lease_expires_at": nil, "updated_at": completedAt, + }) + return result.RowsAffected == 1, result.Error +} + +func (r *Repository) UpdateTaskTerminalDiagnostic(task *model.Task, completedAt time.Time) (bool, error) { + result := taskLeaseWriter(r.db.Model(&model.Task{}), task.LeaseOwner). + Where("id = ? AND status = ?", task.ID, model.TaskStatusRunning). + Updates(map[string]any{ + "status": task.Status, "stage": task.Stage, "error": task.Error, "completed_at": &completedAt, + "execution_diagnostic_json": task.ExecutionDiagnosticJSON, + "cancellation_source": task.CancellationSource, "cancellation_actor_id": task.CancellationActorID, + "cancellation_requested_at": task.CancellationRequestedAt, + "lease_owner": "", "lease_expires_at": nil, "updated_at": completedAt, + }) + return result.RowsAffected == 1, result.Error +} + +func (r *Repository) UpdateTaskExecutionDiagnostic(userID, taskID, diagnosticJSON string) error { + result := r.db.Model(&model.Task{}). + Where("id = ? AND user_id = ?", taskID, userID). + Update("execution_diagnostic_json", diagnosticJSON) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (r *Repository) CancelTaskIfStatus(userID string, id string, expected model.TaskStatus, now time.Time, intents ...model.TaskCancellationIntent) (bool, error) { + updates := map[string]any{ + "status": model.TaskStatusCancelled, "stage": "任务已取消", "error": "任务已取消", "completed_at": &now, + "lease_owner": "", "lease_expires_at": nil, "updated_at": now, + } + if len(intents) > 0 { + intent := intents[0] + diagnostic, err := json.Marshal(intent.Diagnostic) + if err != nil { + return false, err + } + updates["cancellation_source"], updates["cancellation_actor_id"], updates["cancellation_requested_at"] = intent.Source, intent.ActorID, intent.RequestedAt + updates["execution_diagnostic_json"] = string(diagnostic) + } + result := r.db.Model(&model.Task{}). + Where("id = ? AND user_id = ? AND status = ?", id, userID, expected). + Updates(updates) + return result.RowsAffected == 1, result.Error +} + +// 上游取消先落库再发请求;条件更新保证并发和重复取消只有一个调用方取得发送权。 +func (r *Repository) ClaimTaskProviderCancellation(userID string, id string, now time.Time) error { + result := r.db.Model(&model.Task{}). + Where("id = ? AND user_id = ? AND status = ? AND provider_cancel_status = ''", id, userID, model.TaskStatusCancelled). + Updates(map[string]any{ + "provider_cancel_status": model.ProviderCancelStatusRequested, + "provider_cancel_attempts": 1, + "provider_cancel_requested_at": &now, + "provider_cancel_next_check_at": now.Add(15 * time.Second), + "provider_cancel_error": "", + "updated_at": now, + }) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrTaskProviderCancellationConflict + } + return nil +} + +func (r *Repository) UpdateTaskProviderCancellation(id string, expected model.ProviderCancelStatus, status model.ProviderCancelStatus, errorText string, nextCheckAt *time.Time, cancelledAt *time.Time) error { + updates := map[string]any{ + "provider_cancel_status": status, + "provider_cancel_error": errorText, + "provider_cancel_next_check_at": nextCheckAt, + "lease_owner": "", + "lease_expires_at": nil, + "updated_at": time.Now(), + } + if cancelledAt != nil { + updates["provider_cancelled_at"] = cancelledAt + } + result := r.db.Model(&model.Task{}).Where("id = ? AND status = ? AND provider_cancel_status = ?", id, model.TaskStatusCancelled, expected).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrTaskProviderCancellationConflict + } + return nil +} + +// 对账任务同样使用数据库租约,多实例和服务重启后只会有一个 worker 查询同一上游任务。 +func (r *Repository) ClaimNextTaskProviderCancellation(owner string, leaseDuration time.Duration) (*model.Task, error) { + var task model.Task + now := time.Now() + err := r.db.Transaction(func(tx *gorm.DB) error { + query := tx.Where( + "status = ? AND provider_cancel_status = ? AND (provider_cancel_next_check_at IS NULL OR provider_cancel_next_check_at <= ?) AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", + model.TaskStatusCancelled, model.ProviderCancelStatusRequested, now, now, + ).Order("provider_cancel_requested_at asc").Limit(1) + if r.Dialect() == "postgres" { + query = query.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}) + } + if result := query.Find(&task); result.Error != nil || result.RowsAffected == 0 { + task = model.Task{} + return result.Error + } + claim := tx.Model(&model.Task{}).Where( + "id = ? AND status = ? AND provider_cancel_status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", + task.ID, model.TaskStatusCancelled, model.ProviderCancelStatusRequested, now, + ).Updates(map[string]any{ + "lease_owner": owner, + "lease_expires_at": now.Add(leaseDuration), + "provider_cancel_attempts": gorm.Expr("provider_cancel_attempts + 1"), + "updated_at": now, + }) + if claim.Error != nil || claim.RowsAffected == 0 { + task = model.Task{} + return claim.Error + } + return tx.First(&task, "id = ?", task.ID).Error + }) + if err != nil || task.ID == "" { + return nil, err + } + return &task, nil +} + +func (r *Repository) Tasks(userID string, limit int, projectID string, activeOnly bool) ([]model.Task, error) { + var tasks []model.Task + if limit <= 0 || limit > 100 { + limit = 50 + } + query := r.db.Select("id", "project_id", "type", "status", "stage", "media_stage", "media_recovery_json", "progress", "prompt", "operation", "provider", "model", "input_json", "result_json", "billing_order_id", "provider_request_id", "provider_cancel_status", "provider_cancel_error", "provider_cancel_attempts", "provider_cancel_requested_at", "provider_cancelled_at", "provider_cancel_next_check_at", "attempts", "started_at", "completed_at", "created_at", "updated_at", "agent_run_id", "generation_id", "approval_id", "authorized_charge_microcredits", "execution_diagnostic_json", "cancellation_source", "cancellation_actor_id", "cancellation_requested_at"). + Where("user_id = ?", userID) + if strings.TrimSpace(projectID) != "" { + query = query.Where("project_id = ?", strings.TrimSpace(projectID)) + } + if activeOnly { + query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) + } + err := query.Order("created_at desc").Limit(limit).Find(&tasks).Error + return tasks, err +} + +// SuccessfulWorkflowTasksForProject 返回需要补偿工作流产物的成功任务。 +// 不设分页,供项目详情读取时修复浏览器中断造成的历史断点。 +func (r *Repository) SuccessfulWorkflowTasksForProject(userID string, projectID string) ([]model.Task, error) { + var tasks []model.Task + err := r.db.Where("user_id = ? AND project_id = ? AND status = ?", userID, projectID, model.TaskStatusSucceeded). + Order("completed_at asc, created_at asc").Find(&tasks).Error + return tasks, err +} + +func (r *Repository) TaskLogs(userID string, taskID string) ([]model.TaskLog, error) { + var logs []model.TaskLog + err := r.db.Order("created_at asc").Find(&logs, "user_id = ? AND task_id = ?", userID, taskID).Error + return logs, err +} diff --git a/backend/internal/repository/repository_users.go b/backend/internal/repository/repository_users.go new file mode 100644 index 000000000..fb956738a --- /dev/null +++ b/backend/internal/repository/repository_users.go @@ -0,0 +1,180 @@ +// 用户、登录会话与邮箱验证码的持久化。 +// +// 注册与重置密码必须在同一事务内消费验证码,防止并发请求重复使用同一个码。 + +package repository + +import ( + "errors" + "strings" + "time" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/model" +) + +func (r *Repository) UserCount() (int64, error) { + var count int64 + err := r.db.Model(&model.User{}).Count(&count).Error + return count, err +} + +func (r *Repository) User(id string) (*model.User, error) { + var user model.User + if err := r.db.First(&user, "id = ?", id).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) UserByAccount(account string) (*model.User, error) { + var user model.User + if err := r.db.Where("lower(username) = lower(?) OR lower(email) = lower(?)", account, account).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) UserByUsername(username string) (*model.User, error) { + var user model.User + if err := r.db.Where("lower(username) = lower(?)", username).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) UserByEmail(email string) (*model.User, error) { + var user model.User + if err := r.db.Where("email <> '' AND lower(email) = lower(?)", email).First(&user).Error; err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) Users() ([]model.User, error) { + var users []model.User + err := r.db.Order("created_at desc").Find(&users).Error + return users, err +} + +func (r *Repository) AdminUsers(keyword string, role model.UserRole, status model.UserStatus, limit int, offset int) ([]model.User, int64, error) { + var users []model.User + var total int64 + query := r.db.Model(&model.User{}) + if value := strings.TrimSpace(keyword); value != "" { + pattern := "%" + strings.ToLower(value) + "%" + query = query.Where("lower(username) LIKE ? OR lower(display_name) LIKE ? OR lower(email) LIKE ?", pattern, pattern, pattern) + } + if role == model.UserRoleAdmin || role == model.UserRoleUser { + query = query.Where("role = ?", role) + } + if status == model.UserStatusActive || status == model.UserStatusDisabled { + query = query.Where("status = ?", status) + } + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if err := query.Order("created_at desc").Limit(limit).Offset(offset).Find(&users).Error; err != nil { + return nil, 0, err + } + return users, total, nil +} + +func (r *Repository) AdminUserReferences() ([]model.User, error) { + var users []model.User + err := r.db.Select("id", "username", "display_name").Order("created_at desc").Limit(100).Find(&users).Error + return users, err +} + +func (r *Repository) ActiveAdminCountExcluding(userID string) (int64, error) { + var count int64 + query := r.db.Model(&model.User{}).Where("role = ? AND status = ?", model.UserRoleAdmin, model.UserStatusActive) + if userID != "" { + query = query.Where("id <> ?", userID) + } + err := query.Count(&count).Error + return count, err +} + +func (r *Repository) AuthSession(id string) (*model.AuthSession, error) { + var session model.AuthSession + if err := r.db.First(&session, "id = ?", id).Error; err != nil { + return nil, err + } + return &session, nil +} + +func (r *Repository) DeleteAuthSession(id string) error { + return r.db.Delete(&model.AuthSession{}, "id = ?", id).Error +} + +func (r *Repository) DeleteExpiredAuthSessions() error { + return r.db.Delete(&model.AuthSession{}, "expires_at <= ?", time.Now()).Error +} + +func (r *Repository) DeleteUserAuthSessions(userID string) error { + return r.db.Delete(&model.AuthSession{}, "user_id = ?", userID).Error +} + +func (r *Repository) LatestEmailVerificationCode(email string, purpose string) (*model.EmailVerificationCode, error) { + var code model.EmailVerificationCode + if err := r.db.Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose).Order("created_at desc").First(&code).Error; err != nil { + return nil, err + } + return &code, nil +} + +func (r *Repository) MarkEmailVerificationCodeUsed(id string, usedAt time.Time) error { + return r.db.Model(&model.EmailVerificationCode{}).Where("id = ? AND used_at IS NULL", id).Update("used_at", usedAt).Error +} + +func (r *Repository) DeleteEmailVerificationCode(id string) error { + return r.db.Delete(&model.EmailVerificationCode{}, "id = ?", id).Error +} + +func (r *Repository) CreateUserWithEmailVerification(user *model.User, verificationCodeID string, usedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + result := tx.Model(&model.EmailVerificationCode{}).Where("id = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, usedAt).Update("used_at", usedAt) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return errors.New("邮箱验证码已失效,请重新获取") + } + return tx.Create(user).Error + }) +} + +func (r *Repository) ResetUserPasswordWithEmailVerification(userID string, email string, purpose string, verificationCodeID string, passwordHash string, usedAt time.Time) error { + return r.db.Transaction(func(tx *gorm.DB) error { + codeResult := tx.Model(&model.EmailVerificationCode{}). + Where("id = ? AND email = ? AND purpose = ? AND used_at IS NULL AND expires_at > ?", verificationCodeID, email, purpose, usedAt). + Update("used_at", usedAt) + if codeResult.Error != nil { + return codeResult.Error + } + if codeResult.RowsAffected != 1 { + return ErrEmailVerificationCodeInvalid + } + + userResult := tx.Model(&model.User{}). + Where("id = ? AND email <> '' AND lower(email) = lower(?) AND status = ? AND password_hash <> ''", userID, email, model.UserStatusActive). + Updates(map[string]any{"password_hash": passwordHash, "updated_at": usedAt}) + if userResult.Error != nil { + return userResult.Error + } + if userResult.RowsAffected != 1 { + return ErrEmailVerificationCodeInvalid + } + if err := tx.Delete(&model.AuthSession{}, "user_id = ?", userID).Error; err != nil { + return err + } + return tx.Model(&model.EmailVerificationCode{}). + Where("email = ? AND purpose = ? AND used_at IS NULL", email, purpose). + Update("used_at", usedAt).Error + }) +} + +func (r *Repository) DeleteExpiredEmailVerificationCodes(now time.Time) error { + return r.db.Delete(&model.EmailVerificationCode{}, "expires_at <= ? OR used_at IS NOT NULL", now).Error +} diff --git a/backend/internal/repository/resource_reference.go b/backend/internal/repository/resource_reference.go index 940b39ea2..cadfd3378 100644 --- a/backend/internal/repository/resource_reference.go +++ b/backend/internal/repository/resource_reference.go @@ -418,8 +418,23 @@ func (r *Repository) DeleteAssetsAndResources(userID string, assetIDs []string, if len(assetIDs) == 0 || len(ownedAssets) != len(assetIDs) { return gorm.ErrRecordNotFound } - // Explicit deletion and archive expiry must never invalidate a snapshot. - if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + if deleteReferencedResources { + // 彻底删除:移除被删资源的画布历史索引,让删除 worker 可以释放物理对象; + // 历史快照正文保留,恢复该版本时由画布断链修复处理失效媒体。 + if len(resourceIDs) > 0 { + // 与历史快照写入串行化,避免清除索引后又插入外键引用。 + if r.Dialect() == "postgres" { + var resources []model.Resource + if err := tx.Select("id").Where("id IN ?", resourceIDs).Order("id").Clauses(clause.Locking{Strength: "UPDATE"}).Find(&resources).Error; err != nil { + return err + } + } + if err := tx.Where("resource_id IN ?", resourceIDs).Delete(&model.CanvasSnapshotResource{}).Error; err != nil { + return err + } + } + } else if err := New(tx).RequireNoCanvasHistoryReferences(resourceIDs); err != nil { + // 受保护删除不得让任何画布历史快照失效。 return err } versionIDs := tx.Model(&model.AssetVersion{}).Select("id").Where("asset_id IN ?", assetIDs) diff --git a/backend/internal/skills/skill_packages.go b/backend/internal/skills/skill_packages.go index 85c8aedea..ff910f2cc 100644 --- a/backend/internal/skills/skill_packages.go +++ b/backend/internal/skills/skill_packages.go @@ -1,13 +1,9 @@ package skills import ( - "archive/zip" - "bytes" "context" "crypto/sha256" - "encoding/base64" "encoding/hex" - "encoding/json" "errors" "fmt" "io" @@ -15,21 +11,17 @@ import ( "mime" "mime/multipart" "net/http" - "net/url" "os" "path" "path/filepath" "sort" - "strconv" "strings" "time" "unicode/utf8" + "gorm.io/gorm" "infinite-canvas/backend/internal/kernel" "infinite-canvas/backend/internal/model" - "infinite-canvas/backend/internal/outbound" - - "gorm.io/gorm" ) const ( @@ -457,724 +449,6 @@ func (s *Service) persistSkillArchive(skillID string, versionID string, archive return packageKey, version, files, nil } -func (s *Service) SkillPackageFiles(userID string, skillID string) ([]SkillPackageFileItem, error) { - snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) - if err != nil { - return nil, err - } - items := make([]SkillPackageFileItem, 0, len(snapshot.files)) - for _, file := range snapshot.files { - items = append(items, skillFileItem(file)) - } - return items, nil -} - -func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) { - _, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath) - if err != nil { - return nil, err - } - binary := !isPreviewText(file.MimeType, file.Path) - if binary { - return &SkillPackageFileContent{File: skillFileItem(*file), Binary: true}, nil - } - if len(content) > maxSkillPreviewBytes { - return nil, kernel.BadAuthRequest("文件超过 512KB,请下载后查看") - } - return &SkillPackageFileContent{File: skillFileItem(*file), Content: string(content)}, nil -} - -func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath string) ([]byte, string, string, error) { - _, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath) - if err != nil { - return nil, "", "", err - } - return content, file.MimeType, path.Base(file.Path), nil -} - -func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPackageBundle, error) { - snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) - if err != nil { - return nil, err - } - bundle := &SkillPackageBundle{SkillID: snapshot.skill.ID, Name: snapshot.skill.Name, Description: snapshot.skill.Description, VersionID: snapshot.version.ID, Version: snapshot.version.VersionLabel, ContentHash: snapshot.version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(snapshot.files))} - for _, file := range snapshot.files { - content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path) - if err != nil { - return nil, err - } - bundle.Files = append(bundle.Files, SkillPackageBundleFile{Path: file.Path, MimeType: file.MimeType, ContentBase64: base64.StdEncoding.EncodeToString(content)}) - } - return bundle, nil -} - -func (s *Service) SearchSkillPackage(userID string, skillID string, query string) ([]SkillFileSearchResult, error) { - snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) - if err != nil { - return nil, err - } - query = strings.TrimSpace(query) - if query == "" || utf8.RuneCountInString(query) > 120 { - return nil, kernel.BadAuthRequest("搜索关键词必须为 1-120 个字符") - } - needle := strings.ToLower(query) - results := make([]SkillFileSearchResult, 0, 20) - for _, file := range snapshot.files { - if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes { - continue - } - content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path) - if err != nil { - return nil, err - } - for lineIndex, line := range strings.Split(string(content), "\n") { - if strings.Contains(strings.ToLower(line), needle) { - results = append(results, SkillFileSearchResult{Path: file.Path, Line: lineIndex + 1, Snippet: kernel.TruncateRunes(strings.TrimSpace(line), 240)}) - if len(results) >= 50 { - break - } - } - } - } - return results, nil -} - -func (s *Service) readSkillPackageSnapshotFile(userID string, skillID string, filePath string) (*skillPackageSnapshot, *model.SkillFile, []byte, error) { - snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) - if err != nil { - return nil, nil, nil, err - } - filePath, err = normalizeSkillPath(filePath) - if err != nil { - return nil, nil, nil, err - } - for index := range snapshot.files { - if snapshot.files[index].Path != filePath { - continue - } - content, err := s.readSkillPackageSnapshotEntry(snapshot, filePath) - return snapshot, &snapshot.files[index], content, err - } - return nil, nil, nil, kernel.BadAuthRequest("技能文件不存在") -} - -func (s *Service) loadSkillPackageSnapshot(userID string, skillID string) (*skillPackageSnapshot, error) { - skill, err := s.visibleSkill(userID, skillID) - if err != nil { - return nil, err - } - version, err := s.repo.SkillVersion(skill.CurrentVersionID) - if errors.Is(err, gorm.ErrRecordNotFound) { - return legacySkillPackageSnapshot(skill) - } - if err != nil { - return nil, err - } - if version.SkillID != skill.ID { - return nil, fmt.Errorf("技能 %s 当前版本归属不一致", skill.ID) - } - files, err := s.repo.SkillFiles(version.ID) - if err != nil { - return nil, err - } - if len(files) == 0 { - return legacySkillPackageSnapshot(skill) - } - if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil { - return nil, err - } - return &skillPackageSnapshot{skill: skill, version: version, files: files}, nil -} - -func (s *Service) readSkillPackageSnapshotEntry(snapshot *skillPackageSnapshot, filePath string) ([]byte, error) { - if snapshot.contents != nil { - content, ok := snapshot.contents[filePath] - if !ok { - return nil, kernel.BadAuthRequest("技能文件不存在") - } - return content, nil - } - return s.readSkillArchiveEntry(snapshot.version, filePath) -} - -func legacySkillPackageSnapshot(skill *model.Skill) (*skillPackageSnapshot, error) { - if strings.TrimSpace(skill.Instruction) == "" { - return nil, fmt.Errorf("技能 %s 缺少当前版本、文件清单和 legacy instruction", skill.ID) - } - content := []byte(skill.Instruction) - files := map[string][]byte{"SKILL.md": content} - digest := sha256.Sum256(content) - hash, total := contentHashAndSize(files) - versionID := strings.TrimSpace(skill.CurrentVersionID) - versionLabel := strings.TrimSpace(skill.VersionLabel) - if versionLabel == "" { - versionLabel = "legacy" - } - version := &model.SkillVersion{ID: versionID, SkillID: skill.ID, VersionLabel: versionLabel, ContentHash: hash, EntryPath: "SKILL.md", FileCount: 1, TotalBytes: total} - file := model.SkillFile{SkillVersionID: versionID, Path: "SKILL.md", Kind: "markdown", MimeType: skillFileMime("SKILL.md", content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} - return &skillPackageSnapshot{skill: skill, version: version, files: []model.SkillFile{file}, legacy: true, contents: files}, nil -} - -func skillSourceTypeForRepair(skill *model.Skill) string { - if value := strings.TrimSpace(skill.SourceType); value != "" { - return value - } - if skill.Source == skillSourceUser { - return "markdown" - } - return "builtin" -} - -func (s *Service) skillPackageHealth(skill *model.Skill) (*model.SkillVersion, []model.SkillFile, error) { - if strings.TrimSpace(skill.CurrentVersionID) == "" { - return nil, nil, gorm.ErrRecordNotFound - } - version, err := s.repo.SkillVersion(skill.CurrentVersionID) - if err != nil { - return nil, nil, err - } - if version.SkillID != skill.ID { - return nil, nil, fmt.Errorf("当前版本归属技能不一致") - } - files, err := s.repo.SkillFiles(version.ID) - if err != nil { - return nil, nil, err - } - if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil { - return nil, nil, err - } - return version, files, nil -} - -func (s *Service) syncSkillPackageMetadata(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { - changed := skill.CurrentVersionID != version.ID || skill.VersionLabel != version.VersionLabel || skill.ContentHash != version.ContentHash || skill.FileCount != len(files) || skill.TotalBytes != version.TotalBytes - if !changed { - return nil - } - skill.CurrentVersionID = version.ID - skill.VersionLabel = version.VersionLabel - skill.ContentHash = version.ContentHash - skill.FileCount = len(files) - skill.TotalBytes = version.TotalBytes - return s.repo.SaveSkill(skill) -} - -func validateSkillPackageSnapshot(s *Service, skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { - if len(files) == 0 || version.FileCount != len(files) || version.TotalBytes < 0 { - return fmt.Errorf("技能 %s 文件元数据不完整", skill.ID) - } - if skill.ContentHash != "" && skill.ContentHash != version.ContentHash { - return fmt.Errorf("技能 %s 内容摘要与当前版本不一致", skill.ID) - } - if skill.FileCount != 0 && skill.FileCount != len(files) { - return fmt.Errorf("技能 %s 文件数量与当前版本不一致", skill.ID) - } - if skill.TotalBytes != 0 && skill.TotalBytes != version.TotalBytes { - return fmt.Errorf("技能 %s 文件大小与当前版本不一致", skill.ID) - } - if version.PackageKey == "" { - return fmt.Errorf("技能 %s 当前版本缺少 ZIP 包", skill.ID) - } - contents, err := readSkillArchiveEntries(s.dataDir, version.PackageKey) - if err != nil { - return fmt.Errorf("读取技能 %s ZIP 包失败: %w", skill.ID, err) - } - byPath := make(map[string]model.SkillFile, len(files)) - var total int64 - for _, file := range files { - if strings.TrimSpace(file.Path) == "" { - return fmt.Errorf("技能 %s 文件清单存在空路径", skill.ID) - } - if version.EntryPath != "" && version.EntryPath != "SKILL.md" { - return fmt.Errorf("技能 %s 当前入口不是 SKILL.md", skill.ID) - } - if _, exists := byPath[file.Path]; exists { - return fmt.Errorf("技能 %s 文件清单包含重复路径 %s", skill.ID, file.Path) - } - byPath[file.Path] = file - content, ok := contents[file.Path] - if !ok { - return fmt.Errorf("技能 %s ZIP 缺少数据库文件 %s", skill.ID, file.Path) - } - digest := sha256.Sum256(content) - if file.Size != int64(len(content)) || file.SHA256 != hex.EncodeToString(digest[:]) { - return fmt.Errorf("技能 %s 文件 %s 元数据与 ZIP 不一致", skill.ID, file.Path) - } - total += int64(len(content)) - } - if _, ok := byPath["SKILL.md"]; !ok { - return fmt.Errorf("技能 %s 文件清单缺少 SKILL.md", skill.ID) - } - if len(contents) != len(files) || total != version.TotalBytes { - return fmt.Errorf("技能 %s ZIP 文件数量或大小与版本元数据不一致", skill.ID) - } - hash, _ := contentHashAndSize(contents) - if version.ContentHash == "" || hash != version.ContentHash { - return fmt.Errorf("技能 %s ZIP 内容摘要与版本元数据不一致", skill.ID) - } - return nil -} - -func readSkillArchiveEntries(dataDir string, packageKey string) (map[string][]byte, error) { - absolute := filepath.Join(dataDir, "skill-packages", filepath.FromSlash(packageKey)) - reader, err := zip.OpenReader(absolute) - if err != nil { - return nil, err - } - defer reader.Close() - if len(reader.File) > maxSkillPackageFiles+64 { - return nil, kernel.BadAuthRequest("技能包文件数量异常") - } - contents := make(map[string][]byte, len(reader.File)) - var total int64 - for _, entry := range reader.File { - if entry.FileInfo().IsDir() { - continue - } - filePath, err := normalizeSkillPath(entry.Name) - if err != nil { - return nil, err - } - if _, exists := contents[filePath]; exists { - return nil, kernel.BadAuthRequest("技能包包含重复文件路径") - } - if entry.UncompressedSize64 > maxSkillFileBytes { - return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } - file, err := entry.Open() - if err != nil { - return nil, err - } - content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) - closeErr := file.Close() - if readErr != nil { - return nil, readErr - } - if closeErr != nil { - return nil, closeErr - } - if len(content) > maxSkillFileBytes { - return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } - total += int64(len(content)) - if total > maxSkillPackageBytes { - return nil, kernel.BadAuthRequest("技能包解压后不能超过 20MB") - } - contents[filePath] = content - } - return contents, nil -} - -func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath string) ([]byte, error) { - absolute := filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(version.PackageKey)) - reader, err := zip.OpenReader(absolute) - if err != nil { - return nil, err - } - defer reader.Close() - for _, entry := range reader.File { - if entry.Name != filePath { - continue - } - file, err := entry.Open() - if err != nil { - return nil, err - } - defer file.Close() - return io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) - } - return nil, kernel.BadAuthRequest("技能文件不存在") -} - -func archiveFromMarkdown(data []byte, fallbackName string, fallbackDescription string) (skillPackageArchive, error) { - if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) { - return skillPackageArchive{}, kernel.BadAuthRequest("Markdown 文件为空、过大或不是 UTF-8") - } - metadata := parseSkillPackageMetadata(data) - if metadata.Name == "" { - metadata.Name = strings.TrimSpace(fallbackName) - } - if metadata.Description == "" { - metadata.Description = strings.TrimSpace(fallbackDescription) - } - files := map[string][]byte{"SKILL.md": data} - return finalizeSkillArchive(files, metadata) -} - -func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) { - reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) - if err != nil { - return skillPackageArchive{}, kernel.BadAuthRequest("ZIP 文件无法解析") - } - if len(reader.File) > maxSkillPackageFiles+64 { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") - } - raw := make(map[string][]byte) - var total int64 - for _, entry := range reader.File { - if entry.FileInfo().IsDir() { - continue - } - if entry.Mode()&os.ModeSymlink != 0 || !entry.Mode().IsRegular() { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包不能包含软链接或特殊文件") - } - entryPath, err := normalizeSkillPath(entry.Name) - if err != nil { - return skillPackageArchive{}, err - } - if strings.HasPrefix(entryPath, "__MACOSX/") || path.Base(entryPath) == ".DS_Store" { - continue - } - if _, exists := raw[entryPath]; exists { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包包含重复文件路径") - } - if entry.UncompressedSize64 > maxSkillFileBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } - file, err := entry.Open() - if err != nil { - return skillPackageArchive{}, err - } - content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) - closeErr := file.Close() - if readErr != nil { - return skillPackageArchive{}, readErr - } - if closeErr != nil { - return skillPackageArchive{}, closeErr - } - if len(content) > maxSkillFileBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") - } - total += int64(len(content)) - if total > maxSkillPackageBytes { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包解压后不能超过 20MB") - } - raw[entryPath] = content - } - files, err := normalizeSkillArchiveRoot(raw, subdir) - if err != nil { - return skillPackageArchive{}, err - } - metadata := parseSkillPackageMetadata(files["SKILL.md"]) - return finalizeSkillArchive(files, metadata) -} - -func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string][]byte, error) { - if len(raw) == 0 { - return nil, kernel.BadAuthRequest("技能包为空") - } - paths := sortedSkillPaths(raw) - firstParts := strings.SplitN(paths[0], "/", 2) - if len(firstParts) == 2 { - wrapper := firstParts[0] + "/" - allWrapped := true - for _, filePath := range paths { - if !strings.HasPrefix(filePath, wrapper) { - allWrapped = false - break - } - } - if allWrapped { - next := make(map[string][]byte, len(raw)) - for filePath, content := range raw { - next[strings.TrimPrefix(filePath, wrapper)] = content - } - raw = next - } - } - subdir = strings.Trim(strings.ReplaceAll(strings.TrimSpace(subdir), "\\", "/"), "/") - if subdir != "" { - normalized, err := normalizeSkillPath(subdir) - if err != nil { - return nil, err - } - prefix := normalized + "/" - filtered := make(map[string][]byte) - for filePath, content := range raw { - if strings.HasPrefix(filePath, prefix) { - filtered[strings.TrimPrefix(filePath, prefix)] = content - } - } - if len(filtered) == 0 { - return nil, kernel.BadAuthRequest("GitHub 子目录不存在或为空") - } - raw = filtered - } - skillRoots := make([]string, 0, 2) - for filePath := range raw { - if filePath == "SKILL.md" { - skillRoots = []string{""} - break - } - if path.Base(filePath) == "SKILL.md" { - skillRoots = append(skillRoots, path.Dir(filePath)) - } - } - if len(skillRoots) == 0 { - return nil, kernel.BadAuthRequest("技能包中缺少 SKILL.md") - } - if len(skillRoots) > 1 { - return nil, kernel.BadAuthRequest("技能包包含多个 SKILL.md,请指定单个技能目录") - } - root := skillRoots[0] - files := make(map[string][]byte) - for filePath, content := range raw { - if root != "" { - prefix := root + "/" - if !strings.HasPrefix(filePath, prefix) { - continue - } - filePath = strings.TrimPrefix(filePath, prefix) - } - files[filePath] = content - } - if len(files) > maxSkillPackageFiles { - return nil, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") - } - return files, nil -} - -func contentHashAndSize(files map[string][]byte) (string, int64) { - hash := sha256.New() - var total int64 - for _, filePath := range sortedSkillPaths(files) { - content := files[filePath] - total += int64(len(content)) - hash.Write([]byte(filePath)) - hash.Write([]byte{0}) - hash.Write(content) - hash.Write([]byte{0}) - } - return hex.EncodeToString(hash.Sum(nil)), total -} - -func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) { - if _, ok := files["SKILL.md"]; !ok { - return skillPackageArchive{}, kernel.BadAuthRequest("技能包入口必须是 SKILL.md") - } - if metadata.Name == "" { - return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 name,且无法从标题推断") - } - if metadata.Description == "" { - return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 description,且无法从正文推断") - } - if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 { - return skillPackageArchive{}, kernel.BadAuthRequest("技能名称或简介超过长度限制") - } - hash, total := contentHashAndSize(files) - return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hash, TotalBytes: total}, nil -} - -func encodeSkillArchive(files map[string][]byte) ([]byte, error) { - var buffer bytes.Buffer - writer := zip.NewWriter(&buffer) - for _, filePath := range sortedSkillPaths(files) { - header := &zip.FileHeader{Name: filePath, Method: zip.Deflate} - header.SetMode(0o600) - header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)) - entry, err := writer.CreateHeader(header) - if err != nil { - return nil, err - } - if _, err := entry.Write(files[filePath]); err != nil { - return nil, err - } - } - if err := writer.Close(); err != nil { - return nil, err - } - return buffer.Bytes(), nil -} - -func parseSkillPackageMetadata(data []byte) skillPackageMetadata { - text := string(data) - lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") - metadata := skillPackageMetadata{} - bodyStart := 0 - if len(lines) > 2 && strings.TrimSpace(lines[0]) == "---" { - metadataIndent := -1 - for index := 1; index < len(lines); index++ { - line := lines[index] - if strings.TrimSpace(line) == "---" { - bodyStart = index + 1 - break - } - trimmed := strings.TrimSpace(line) - indent := len(line) - len(strings.TrimLeft(line, " \t")) - if indent == 0 { - metadataIndent = -1 - } - key, value, ok := strings.Cut(trimmed, ":") - if !ok { - continue - } - key = strings.TrimSpace(key) - value = yamlScalar(value) - switch { - case indent == 0 && key == "name": - metadata.Name = value - case indent == 0 && key == "description": - metadata.Description = value - case indent == 0 && key == "metadata": - metadataIndent = indent - case metadataIndent >= 0 && indent > metadataIndent && key == "version": - metadata.Version = value - } - } - } - body := lines[bodyStart:] - if metadata.Name == "" { - for _, line := range body { - if strings.HasPrefix(strings.TrimSpace(line), "# ") { - metadata.Name = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "# ")) - break - } - } - } - if metadata.Description == "" { - paragraph := make([]string, 0, 4) - for _, line := range body { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - if len(paragraph) > 0 { - break - } - continue - } - if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "```") { - continue - } - paragraph = append(paragraph, trimmed) - } - metadata.Description = strings.Join(paragraph, " ") - } - metadata.Name = truncateSkillMetadata(metadata.Name, 80) - metadata.Description = truncateSkillMetadata(metadata.Description, 500) - metadata.Version = truncateSkillMetadata(metadata.Version, 64) - return metadata -} - -// truncateSkillMetadata keeps the result within the validation limit. The -// general-purpose TruncateRunes helper appends "...", which is useful for -// display snippets but would make a value exactly at the limit invalid. -func truncateSkillMetadata(value string, limit int) string { - value = strings.TrimSpace(value) - if limit <= 0 { - return "" - } - runes := []rune(value) - if len(runes) <= limit { - return value - } - if limit <= 3 { - return string(runes[:limit]) - } - return string(runes[:limit-3]) + "..." -} - -func yamlScalar(value string) string { - value = strings.TrimSpace(value) - if len(value) >= 2 && value[0] == '"' { - if decoded, err := strconv.Unquote(value); err == nil { - return strings.TrimSpace(decoded) - } - } - if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { - return strings.TrimSpace(value[1 : len(value)-1]) - } - return value -} - -func parseGitHubSkillURL(rawURL string, requestedRef string, requestedSubdir string) (githubSkillSpec, error) { - target, err := url.Parse(strings.TrimSpace(rawURL)) - if err != nil || target.Scheme != "https" || !strings.EqualFold(target.Hostname(), "github.com") || target.User != nil { - return githubSkillSpec{}, kernel.BadAuthRequest("请输入公开的 https://github.com 仓库地址") - } - parts := strings.Split(strings.Trim(target.Path, "/"), "/") - if len(parts) < 2 { - return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须包含 owner/repository") - } - spec := githubSkillSpec{Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git"), Ref: strings.TrimSpace(requestedRef), Subdir: strings.Trim(strings.ReplaceAll(strings.TrimSpace(requestedSubdir), "\\", "/"), "/")} - if len(parts) >= 4 && parts[2] == "tree" { - if spec.Ref == "" { - spec.Ref = parts[3] - } - if spec.Subdir == "" && len(parts) > 4 { - spec.Subdir = strings.Join(parts[4:], "/") - } - } - if len(parts) > 2 && parts[2] != "tree" { - return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须指向仓库根目录或 tree 子目录") - } - if spec.Owner == "" || spec.Repo == "" { - return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 仓库地址无效") - } - return spec, nil -} - -func fetchGitHubSkillArchive(ctx context.Context, spec githubSkillSpec) (skillPackageArchive, string, string, string, error) { - client := &http.Client{Timeout: 30 * time.Second} - resolvedRef := spec.Ref - if resolvedRef == "" { - var repoInfo struct { - DefaultBranch string `json:"default_branch"` - } - if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo)), &repoInfo); err != nil { - return skillPackageArchive{}, "", "", "", err - } - resolvedRef = repoInfo.DefaultBranch - } - var commitInfo struct { - SHA string `json:"sha"` - } - if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(resolvedRef)), &commitInfo); err != nil { - return skillPackageArchive{}, "", "", "", err - } - if commitInfo.SHA == "" { - return skillPackageArchive{}, "", "", "", errors.New("GitHub 未返回提交版本") - } - downloadURL := fmt.Sprintf("https://codeload.github.com/%s/%s/zip/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(commitInfo.SHA)) - request, _ := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) - request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent) - response, err := client.Do(request) - if err != nil { - return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: %w", err) - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: HTTP %d", response.StatusCode) - } - data, err := io.ReadAll(io.LimitReader(response.Body, maxSkillPackageBytes+1)) - if err != nil { - return skillPackageArchive{}, "", "", "", err - } - if len(data) > maxSkillPackageBytes { - return skillPackageArchive{}, "", "", "", kernel.BadAuthRequest("GitHub 技能包超过 20MB") - } - archive, err := archiveFromZip(data, spec.Subdir) - if err != nil { - return skillPackageArchive{}, "", "", "", err - } - canonicalURL := fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo) - return archive, commitInfo.SHA, canonicalURL, resolvedRef, nil -} - -func githubJSON(ctx context.Context, client *http.Client, target string, output any) error { - request, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) - request.Header.Set("Accept", "application/vnd.github+json") - request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent) - response, err := client.Do(request) - if err != nil { - return fmt.Errorf("读取 GitHub 信息失败: %w", err) - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("读取 GitHub 信息失败: HTTP %d", response.StatusCode) - } - return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(output) -} - func normalizeSkillPath(value string) (string, error) { value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/") if value == "" || strings.ContainsRune(value, 0) || strings.HasPrefix(value, "/") { diff --git a/backend/internal/skills/skill_packages_archive.go b/backend/internal/skills/skill_packages_archive.go new file mode 100644 index 000000000..4430e33a4 --- /dev/null +++ b/backend/internal/skills/skill_packages_archive.go @@ -0,0 +1,394 @@ +// 技能包归档的读取与规范化:Markdown / zip 两种来源、根目录识别、元数据解析与内容哈希。 +// +// 压缩包条目逐个校验路径与大小(maxSkillFileBytes / maxSkillPackageFiles),防止路径穿越与压缩炸弹。 + +package skills + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "io" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" + + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func readSkillArchiveEntries(dataDir string, packageKey string) (map[string][]byte, error) { + absolute := filepath.Join(dataDir, "skill-packages", filepath.FromSlash(packageKey)) + reader, err := zip.OpenReader(absolute) + if err != nil { + return nil, err + } + defer reader.Close() + if len(reader.File) > maxSkillPackageFiles+64 { + return nil, kernel.BadAuthRequest("技能包文件数量异常") + } + contents := make(map[string][]byte, len(reader.File)) + var total int64 + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + filePath, err := normalizeSkillPath(entry.Name) + if err != nil { + return nil, err + } + if _, exists := contents[filePath]; exists { + return nil, kernel.BadAuthRequest("技能包包含重复文件路径") + } + if entry.UncompressedSize64 > maxSkillFileBytes { + return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") + } + file, err := entry.Open() + if err != nil { + return nil, err + } + content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + closeErr := file.Close() + if readErr != nil { + return nil, readErr + } + if closeErr != nil { + return nil, closeErr + } + if len(content) > maxSkillFileBytes { + return nil, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") + } + total += int64(len(content)) + if total > maxSkillPackageBytes { + return nil, kernel.BadAuthRequest("技能包解压后不能超过 20MB") + } + contents[filePath] = content + } + return contents, nil +} + +func (s *Service) readSkillArchiveEntry(version *model.SkillVersion, filePath string) ([]byte, error) { + absolute := filepath.Join(s.dataDir, "skill-packages", filepath.FromSlash(version.PackageKey)) + reader, err := zip.OpenReader(absolute) + if err != nil { + return nil, err + } + defer reader.Close() + for _, entry := range reader.File { + if entry.Name != filePath { + continue + } + file, err := entry.Open() + if err != nil { + return nil, err + } + defer file.Close() + return io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + } + return nil, kernel.BadAuthRequest("技能文件不存在") +} + +func archiveFromMarkdown(data []byte, fallbackName string, fallbackDescription string) (skillPackageArchive, error) { + if len(data) == 0 || len(data) > maxSkillFileBytes || !utf8.Valid(data) { + return skillPackageArchive{}, kernel.BadAuthRequest("Markdown 文件为空、过大或不是 UTF-8") + } + metadata := parseSkillPackageMetadata(data) + if metadata.Name == "" { + metadata.Name = strings.TrimSpace(fallbackName) + } + if metadata.Description == "" { + metadata.Description = strings.TrimSpace(fallbackDescription) + } + files := map[string][]byte{"SKILL.md": data} + return finalizeSkillArchive(files, metadata) +} + +func archiveFromZip(data []byte, subdir string) (skillPackageArchive, error) { + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return skillPackageArchive{}, kernel.BadAuthRequest("ZIP 文件无法解析") + } + if len(reader.File) > maxSkillPackageFiles+64 { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") + } + raw := make(map[string][]byte) + var total int64 + for _, entry := range reader.File { + if entry.FileInfo().IsDir() { + continue + } + if entry.Mode()&os.ModeSymlink != 0 || !entry.Mode().IsRegular() { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包不能包含软链接或特殊文件") + } + entryPath, err := normalizeSkillPath(entry.Name) + if err != nil { + return skillPackageArchive{}, err + } + if strings.HasPrefix(entryPath, "__MACOSX/") || path.Base(entryPath) == ".DS_Store" { + continue + } + if _, exists := raw[entryPath]; exists { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包包含重复文件路径") + } + if entry.UncompressedSize64 > maxSkillFileBytes { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") + } + file, err := entry.Open() + if err != nil { + return skillPackageArchive{}, err + } + content, readErr := io.ReadAll(io.LimitReader(file, maxSkillFileBytes+1)) + closeErr := file.Close() + if readErr != nil { + return skillPackageArchive{}, readErr + } + if closeErr != nil { + return skillPackageArchive{}, closeErr + } + if len(content) > maxSkillFileBytes { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包中单个文件不能超过 8MB") + } + total += int64(len(content)) + if total > maxSkillPackageBytes { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包解压后不能超过 20MB") + } + raw[entryPath] = content + } + files, err := normalizeSkillArchiveRoot(raw, subdir) + if err != nil { + return skillPackageArchive{}, err + } + metadata := parseSkillPackageMetadata(files["SKILL.md"]) + return finalizeSkillArchive(files, metadata) +} + +func normalizeSkillArchiveRoot(raw map[string][]byte, subdir string) (map[string][]byte, error) { + if len(raw) == 0 { + return nil, kernel.BadAuthRequest("技能包为空") + } + paths := sortedSkillPaths(raw) + firstParts := strings.SplitN(paths[0], "/", 2) + if len(firstParts) == 2 { + wrapper := firstParts[0] + "/" + allWrapped := true + for _, filePath := range paths { + if !strings.HasPrefix(filePath, wrapper) { + allWrapped = false + break + } + } + if allWrapped { + next := make(map[string][]byte, len(raw)) + for filePath, content := range raw { + next[strings.TrimPrefix(filePath, wrapper)] = content + } + raw = next + } + } + subdir = strings.Trim(strings.ReplaceAll(strings.TrimSpace(subdir), "\\", "/"), "/") + if subdir != "" { + normalized, err := normalizeSkillPath(subdir) + if err != nil { + return nil, err + } + prefix := normalized + "/" + filtered := make(map[string][]byte) + for filePath, content := range raw { + if strings.HasPrefix(filePath, prefix) { + filtered[strings.TrimPrefix(filePath, prefix)] = content + } + } + if len(filtered) == 0 { + return nil, kernel.BadAuthRequest("GitHub 子目录不存在或为空") + } + raw = filtered + } + skillRoots := make([]string, 0, 2) + for filePath := range raw { + if filePath == "SKILL.md" { + skillRoots = []string{""} + break + } + if path.Base(filePath) == "SKILL.md" { + skillRoots = append(skillRoots, path.Dir(filePath)) + } + } + if len(skillRoots) == 0 { + return nil, kernel.BadAuthRequest("技能包中缺少 SKILL.md") + } + if len(skillRoots) > 1 { + return nil, kernel.BadAuthRequest("技能包包含多个 SKILL.md,请指定单个技能目录") + } + root := skillRoots[0] + files := make(map[string][]byte) + for filePath, content := range raw { + if root != "" { + prefix := root + "/" + if !strings.HasPrefix(filePath, prefix) { + continue + } + filePath = strings.TrimPrefix(filePath, prefix) + } + files[filePath] = content + } + if len(files) > maxSkillPackageFiles { + return nil, kernel.BadAuthRequest("技能包文件数量不能超过 512 个") + } + return files, nil +} + +func contentHashAndSize(files map[string][]byte) (string, int64) { + hash := sha256.New() + var total int64 + for _, filePath := range sortedSkillPaths(files) { + content := files[filePath] + total += int64(len(content)) + hash.Write([]byte(filePath)) + hash.Write([]byte{0}) + hash.Write(content) + hash.Write([]byte{0}) + } + return hex.EncodeToString(hash.Sum(nil)), total +} + +func finalizeSkillArchive(files map[string][]byte, metadata skillPackageMetadata) (skillPackageArchive, error) { + if _, ok := files["SKILL.md"]; !ok { + return skillPackageArchive{}, kernel.BadAuthRequest("技能包入口必须是 SKILL.md") + } + if metadata.Name == "" { + return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 name,且无法从标题推断") + } + if metadata.Description == "" { + return skillPackageArchive{}, kernel.BadAuthRequest("SKILL.md 缺少 description,且无法从正文推断") + } + if utf8.RuneCountInString(metadata.Name) > 80 || utf8.RuneCountInString(metadata.Description) > 500 { + return skillPackageArchive{}, kernel.BadAuthRequest("技能名称或简介超过长度限制") + } + hash, total := contentHashAndSize(files) + return skillPackageArchive{Files: files, Metadata: metadata, ContentHash: hash, TotalBytes: total}, nil +} + +func encodeSkillArchive(files map[string][]byte) ([]byte, error) { + var buffer bytes.Buffer + writer := zip.NewWriter(&buffer) + for _, filePath := range sortedSkillPaths(files) { + header := &zip.FileHeader{Name: filePath, Method: zip.Deflate} + header.SetMode(0o600) + header.SetModTime(time.Date(1980, 1, 1, 0, 0, 0, 0, time.UTC)) + entry, err := writer.CreateHeader(header) + if err != nil { + return nil, err + } + if _, err := entry.Write(files[filePath]); err != nil { + return nil, err + } + } + if err := writer.Close(); err != nil { + return nil, err + } + return buffer.Bytes(), nil +} + +func parseSkillPackageMetadata(data []byte) skillPackageMetadata { + text := string(data) + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + metadata := skillPackageMetadata{} + bodyStart := 0 + if len(lines) > 2 && strings.TrimSpace(lines[0]) == "---" { + metadataIndent := -1 + for index := 1; index < len(lines); index++ { + line := lines[index] + if strings.TrimSpace(line) == "---" { + bodyStart = index + 1 + break + } + trimmed := strings.TrimSpace(line) + indent := len(line) - len(strings.TrimLeft(line, " \t")) + if indent == 0 { + metadataIndent = -1 + } + key, value, ok := strings.Cut(trimmed, ":") + if !ok { + continue + } + key = strings.TrimSpace(key) + value = yamlScalar(value) + switch { + case indent == 0 && key == "name": + metadata.Name = value + case indent == 0 && key == "description": + metadata.Description = value + case indent == 0 && key == "metadata": + metadataIndent = indent + case metadataIndent >= 0 && indent > metadataIndent && key == "version": + metadata.Version = value + } + } + } + body := lines[bodyStart:] + if metadata.Name == "" { + for _, line := range body { + if strings.HasPrefix(strings.TrimSpace(line), "# ") { + metadata.Name = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "# ")) + break + } + } + } + if metadata.Description == "" { + paragraph := make([]string, 0, 4) + for _, line := range body { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + if len(paragraph) > 0 { + break + } + continue + } + if strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, "```") { + continue + } + paragraph = append(paragraph, trimmed) + } + metadata.Description = strings.Join(paragraph, " ") + } + metadata.Name = truncateSkillMetadata(metadata.Name, 80) + metadata.Description = truncateSkillMetadata(metadata.Description, 500) + metadata.Version = truncateSkillMetadata(metadata.Version, 64) + return metadata +} + +// truncateSkillMetadata keeps the result within the validation limit. The +// general-purpose TruncateRunes helper appends "...", which is useful for +// display snippets but would make a value exactly at the limit invalid. +func truncateSkillMetadata(value string, limit int) string { + value = strings.TrimSpace(value) + if limit <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= limit { + return value + } + if limit <= 3 { + return string(runes[:limit]) + } + return string(runes[:limit-3]) + "..." +} + +func yamlScalar(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && value[0] == '"' { + if decoded, err := strconv.Unquote(value); err == nil { + return strings.TrimSpace(decoded) + } + } + if len(value) >= 2 && value[0] == '\'' && value[len(value)-1] == '\'' { + return strings.TrimSpace(value[1 : len(value)-1]) + } + return value +} diff --git a/backend/internal/skills/skill_packages_files.go b/backend/internal/skills/skill_packages_files.go new file mode 100644 index 000000000..abf1ac11c --- /dev/null +++ b/backend/internal/skills/skill_packages_files.go @@ -0,0 +1,276 @@ +// 技能包内文件的浏览、读取、打包下载与全文检索。 + +package skills + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "path" + "strings" + "unicode/utf8" + + "gorm.io/gorm" + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/model" +) + +func (s *Service) SkillPackageFiles(userID string, skillID string) ([]SkillPackageFileItem, error) { + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) + if err != nil { + return nil, err + } + items := make([]SkillPackageFileItem, 0, len(snapshot.files)) + for _, file := range snapshot.files { + items = append(items, skillFileItem(file)) + } + return items, nil +} + +func (s *Service) SkillPackageFile(userID string, skillID string, filePath string) (*SkillPackageFileContent, error) { + _, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath) + if err != nil { + return nil, err + } + binary := !isPreviewText(file.MimeType, file.Path) + if binary { + return &SkillPackageFileContent{File: skillFileItem(*file), Binary: true}, nil + } + if len(content) > maxSkillPreviewBytes { + return nil, kernel.BadAuthRequest("文件超过 512KB,请下载后查看") + } + return &SkillPackageFileContent{File: skillFileItem(*file), Content: string(content)}, nil +} + +func (s *Service) SkillPackageRawFile(userID string, skillID string, filePath string) ([]byte, string, string, error) { + _, file, content, err := s.readSkillPackageSnapshotFile(userID, skillID, filePath) + if err != nil { + return nil, "", "", err + } + return content, file.MimeType, path.Base(file.Path), nil +} + +func (s *Service) SkillPackageBundle(userID string, skillID string) (*SkillPackageBundle, error) { + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) + if err != nil { + return nil, err + } + bundle := &SkillPackageBundle{SkillID: snapshot.skill.ID, Name: snapshot.skill.Name, Description: snapshot.skill.Description, VersionID: snapshot.version.ID, Version: snapshot.version.VersionLabel, ContentHash: snapshot.version.ContentHash, Files: make([]SkillPackageBundleFile, 0, len(snapshot.files))} + for _, file := range snapshot.files { + content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path) + if err != nil { + return nil, err + } + bundle.Files = append(bundle.Files, SkillPackageBundleFile{Path: file.Path, MimeType: file.MimeType, ContentBase64: base64.StdEncoding.EncodeToString(content)}) + } + return bundle, nil +} + +func (s *Service) SearchSkillPackage(userID string, skillID string, query string) ([]SkillFileSearchResult, error) { + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) + if err != nil { + return nil, err + } + query = strings.TrimSpace(query) + if query == "" || utf8.RuneCountInString(query) > 120 { + return nil, kernel.BadAuthRequest("搜索关键词必须为 1-120 个字符") + } + needle := strings.ToLower(query) + results := make([]SkillFileSearchResult, 0, 20) + for _, file := range snapshot.files { + if len(results) >= 50 || !isPreviewText(file.MimeType, file.Path) || file.Size > maxSkillPreviewBytes { + continue + } + content, err := s.readSkillPackageSnapshotEntry(snapshot, file.Path) + if err != nil { + return nil, err + } + for lineIndex, line := range strings.Split(string(content), "\n") { + if strings.Contains(strings.ToLower(line), needle) { + results = append(results, SkillFileSearchResult{Path: file.Path, Line: lineIndex + 1, Snippet: kernel.TruncateRunes(strings.TrimSpace(line), 240)}) + if len(results) >= 50 { + break + } + } + } + } + return results, nil +} + +func (s *Service) readSkillPackageSnapshotFile(userID string, skillID string, filePath string) (*skillPackageSnapshot, *model.SkillFile, []byte, error) { + snapshot, err := s.loadSkillPackageSnapshot(userID, skillID) + if err != nil { + return nil, nil, nil, err + } + filePath, err = normalizeSkillPath(filePath) + if err != nil { + return nil, nil, nil, err + } + for index := range snapshot.files { + if snapshot.files[index].Path != filePath { + continue + } + content, err := s.readSkillPackageSnapshotEntry(snapshot, filePath) + return snapshot, &snapshot.files[index], content, err + } + return nil, nil, nil, kernel.BadAuthRequest("技能文件不存在") +} + +func (s *Service) loadSkillPackageSnapshot(userID string, skillID string) (*skillPackageSnapshot, error) { + skill, err := s.visibleSkill(userID, skillID) + if err != nil { + return nil, err + } + version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if errors.Is(err, gorm.ErrRecordNotFound) { + return legacySkillPackageSnapshot(skill) + } + if err != nil { + return nil, err + } + if version.SkillID != skill.ID { + return nil, fmt.Errorf("技能 %s 当前版本归属不一致", skill.ID) + } + files, err := s.repo.SkillFiles(version.ID) + if err != nil { + return nil, err + } + if len(files) == 0 { + return legacySkillPackageSnapshot(skill) + } + if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil { + return nil, err + } + return &skillPackageSnapshot{skill: skill, version: version, files: files}, nil +} + +func (s *Service) readSkillPackageSnapshotEntry(snapshot *skillPackageSnapshot, filePath string) ([]byte, error) { + if snapshot.contents != nil { + content, ok := snapshot.contents[filePath] + if !ok { + return nil, kernel.BadAuthRequest("技能文件不存在") + } + return content, nil + } + return s.readSkillArchiveEntry(snapshot.version, filePath) +} + +func legacySkillPackageSnapshot(skill *model.Skill) (*skillPackageSnapshot, error) { + if strings.TrimSpace(skill.Instruction) == "" { + return nil, fmt.Errorf("技能 %s 缺少当前版本、文件清单和 legacy instruction", skill.ID) + } + content := []byte(skill.Instruction) + files := map[string][]byte{"SKILL.md": content} + digest := sha256.Sum256(content) + hash, total := contentHashAndSize(files) + versionID := strings.TrimSpace(skill.CurrentVersionID) + versionLabel := strings.TrimSpace(skill.VersionLabel) + if versionLabel == "" { + versionLabel = "legacy" + } + version := &model.SkillVersion{ID: versionID, SkillID: skill.ID, VersionLabel: versionLabel, ContentHash: hash, EntryPath: "SKILL.md", FileCount: 1, TotalBytes: total} + file := model.SkillFile{SkillVersionID: versionID, Path: "SKILL.md", Kind: "markdown", MimeType: skillFileMime("SKILL.md", content), Size: int64(len(content)), SHA256: hex.EncodeToString(digest[:])} + return &skillPackageSnapshot{skill: skill, version: version, files: []model.SkillFile{file}, legacy: true, contents: files}, nil +} + +func skillSourceTypeForRepair(skill *model.Skill) string { + if value := strings.TrimSpace(skill.SourceType); value != "" { + return value + } + if skill.Source == skillSourceUser { + return "markdown" + } + return "builtin" +} + +func (s *Service) skillPackageHealth(skill *model.Skill) (*model.SkillVersion, []model.SkillFile, error) { + if strings.TrimSpace(skill.CurrentVersionID) == "" { + return nil, nil, gorm.ErrRecordNotFound + } + version, err := s.repo.SkillVersion(skill.CurrentVersionID) + if err != nil { + return nil, nil, err + } + if version.SkillID != skill.ID { + return nil, nil, fmt.Errorf("当前版本归属技能不一致") + } + files, err := s.repo.SkillFiles(version.ID) + if err != nil { + return nil, nil, err + } + if err := validateSkillPackageSnapshot(s, skill, version, files); err != nil { + return nil, nil, err + } + return version, files, nil +} + +func (s *Service) syncSkillPackageMetadata(skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { + changed := skill.CurrentVersionID != version.ID || skill.VersionLabel != version.VersionLabel || skill.ContentHash != version.ContentHash || skill.FileCount != len(files) || skill.TotalBytes != version.TotalBytes + if !changed { + return nil + } + skill.CurrentVersionID = version.ID + skill.VersionLabel = version.VersionLabel + skill.ContentHash = version.ContentHash + skill.FileCount = len(files) + skill.TotalBytes = version.TotalBytes + return s.repo.SaveSkill(skill) +} + +func validateSkillPackageSnapshot(s *Service, skill *model.Skill, version *model.SkillVersion, files []model.SkillFile) error { + if len(files) == 0 || version.FileCount != len(files) || version.TotalBytes < 0 { + return fmt.Errorf("技能 %s 文件元数据不完整", skill.ID) + } + if skill.ContentHash != "" && skill.ContentHash != version.ContentHash { + return fmt.Errorf("技能 %s 内容摘要与当前版本不一致", skill.ID) + } + if skill.FileCount != 0 && skill.FileCount != len(files) { + return fmt.Errorf("技能 %s 文件数量与当前版本不一致", skill.ID) + } + if skill.TotalBytes != 0 && skill.TotalBytes != version.TotalBytes { + return fmt.Errorf("技能 %s 文件大小与当前版本不一致", skill.ID) + } + if version.PackageKey == "" { + return fmt.Errorf("技能 %s 当前版本缺少 ZIP 包", skill.ID) + } + contents, err := readSkillArchiveEntries(s.dataDir, version.PackageKey) + if err != nil { + return fmt.Errorf("读取技能 %s ZIP 包失败: %w", skill.ID, err) + } + byPath := make(map[string]model.SkillFile, len(files)) + var total int64 + for _, file := range files { + if strings.TrimSpace(file.Path) == "" { + return fmt.Errorf("技能 %s 文件清单存在空路径", skill.ID) + } + if version.EntryPath != "" && version.EntryPath != "SKILL.md" { + return fmt.Errorf("技能 %s 当前入口不是 SKILL.md", skill.ID) + } + if _, exists := byPath[file.Path]; exists { + return fmt.Errorf("技能 %s 文件清单包含重复路径 %s", skill.ID, file.Path) + } + byPath[file.Path] = file + content, ok := contents[file.Path] + if !ok { + return fmt.Errorf("技能 %s ZIP 缺少数据库文件 %s", skill.ID, file.Path) + } + digest := sha256.Sum256(content) + if file.Size != int64(len(content)) || file.SHA256 != hex.EncodeToString(digest[:]) { + return fmt.Errorf("技能 %s 文件 %s 元数据与 ZIP 不一致", skill.ID, file.Path) + } + total += int64(len(content)) + } + if _, ok := byPath["SKILL.md"]; !ok { + return fmt.Errorf("技能 %s 文件清单缺少 SKILL.md", skill.ID) + } + if len(contents) != len(files) || total != version.TotalBytes { + return fmt.Errorf("技能 %s ZIP 文件数量或大小与版本元数据不一致", skill.ID) + } + hash, _ := contentHashAndSize(contents) + if version.ContentHash == "" || hash != version.ContentHash { + return fmt.Errorf("技能 %s ZIP 内容摘要与版本元数据不一致", skill.ID) + } + return nil +} diff --git a/backend/internal/skills/skill_packages_github.go b/backend/internal/skills/skill_packages_github.go new file mode 100644 index 000000000..81cc90d23 --- /dev/null +++ b/backend/internal/skills/skill_packages_github.go @@ -0,0 +1,107 @@ +// 从 GitHub 安装与同步技能:解析仓库地址、下载归档、按提交号记录版本。 + +package skills + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "infinite-canvas/backend/internal/kernel" + "infinite-canvas/backend/internal/outbound" +) + +func parseGitHubSkillURL(rawURL string, requestedRef string, requestedSubdir string) (githubSkillSpec, error) { + target, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || target.Scheme != "https" || !strings.EqualFold(target.Hostname(), "github.com") || target.User != nil { + return githubSkillSpec{}, kernel.BadAuthRequest("请输入公开的 https://github.com 仓库地址") + } + parts := strings.Split(strings.Trim(target.Path, "/"), "/") + if len(parts) < 2 { + return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须包含 owner/repository") + } + spec := githubSkillSpec{Owner: parts[0], Repo: strings.TrimSuffix(parts[1], ".git"), Ref: strings.TrimSpace(requestedRef), Subdir: strings.Trim(strings.ReplaceAll(strings.TrimSpace(requestedSubdir), "\\", "/"), "/")} + if len(parts) >= 4 && parts[2] == "tree" { + if spec.Ref == "" { + spec.Ref = parts[3] + } + if spec.Subdir == "" && len(parts) > 4 { + spec.Subdir = strings.Join(parts[4:], "/") + } + } + if len(parts) > 2 && parts[2] != "tree" { + return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 地址必须指向仓库根目录或 tree 子目录") + } + if spec.Owner == "" || spec.Repo == "" { + return githubSkillSpec{}, kernel.BadAuthRequest("GitHub 仓库地址无效") + } + return spec, nil +} + +func fetchGitHubSkillArchive(ctx context.Context, spec githubSkillSpec) (skillPackageArchive, string, string, string, error) { + client := &http.Client{Timeout: 30 * time.Second} + resolvedRef := spec.Ref + if resolvedRef == "" { + var repoInfo struct { + DefaultBranch string `json:"default_branch"` + } + if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo)), &repoInfo); err != nil { + return skillPackageArchive{}, "", "", "", err + } + resolvedRef = repoInfo.DefaultBranch + } + var commitInfo struct { + SHA string `json:"sha"` + } + if err := githubJSON(ctx, client, fmt.Sprintf("https://api.github.com/repos/%s/%s/commits/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(resolvedRef)), &commitInfo); err != nil { + return skillPackageArchive{}, "", "", "", err + } + if commitInfo.SHA == "" { + return skillPackageArchive{}, "", "", "", errors.New("GitHub 未返回提交版本") + } + downloadURL := fmt.Sprintf("https://codeload.github.com/%s/%s/zip/%s", url.PathEscape(spec.Owner), url.PathEscape(spec.Repo), url.PathEscape(commitInfo.SHA)) + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent) + response, err := client.Do(request) + if err != nil { + return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return skillPackageArchive{}, "", "", "", fmt.Errorf("下载 GitHub 技能失败: HTTP %d", response.StatusCode) + } + data, err := io.ReadAll(io.LimitReader(response.Body, maxSkillPackageBytes+1)) + if err != nil { + return skillPackageArchive{}, "", "", "", err + } + if len(data) > maxSkillPackageBytes { + return skillPackageArchive{}, "", "", "", kernel.BadAuthRequest("GitHub 技能包超过 20MB") + } + archive, err := archiveFromZip(data, spec.Subdir) + if err != nil { + return skillPackageArchive{}, "", "", "", err + } + canonicalURL := fmt.Sprintf("https://github.com/%s/%s", spec.Owner, spec.Repo) + return archive, commitInfo.SHA, canonicalURL, resolvedRef, nil +} + +func githubJSON(ctx context.Context, client *http.Client, target string, output any) error { + request, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("User-Agent", outbound.DefaultOutboundUserAgent) + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("读取 GitHub 信息失败: %w", err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("读取 GitHub 信息失败: HTTP %d", response.StatusCode) + } + return json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(output) +} diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index dc947fab3..e3070d139 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,17 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 素材彻底删除恢复不拦截、CI 提速与大文件拆分 + +- 已恢复:`DELETE /assets/:id`、批量删除、清空回收站、回收站素材删除与过期自动清理不再被画布、任务、项目或画布历史快照引用拦截(9 月 21 日 dc1ad680 的契约,9 月 24 日 5b382726 曾误收紧)。事务移除对应历史资源索引,物理对象经 Outbox 异步删除;其他素材复用的资源继续保留。与 `http-api.mdx`、`openapi.yaml` 及删除确认弹窗文案一致。 +- 已修复:审批后的媒体等待在服务停机被取消时,不再继续回写与恢复运行时;`MediaTaskID` 保留,重启后重新挂上等待。 +- CI 提速:每次构造 Service 都会完整解压 85MB 官方插件包(每次约 4 秒,1000 余个用例累计 7 分钟以上)。现启动只读清单、进程内缓存官方包读取结果、支付运行目录只解压本机平台的可执行文件;`internal/app` 本地由约 488 秒降到约 144 秒。发布流程的镜像构建与质量检查并行(可变标签推广仍等待检查通过),PR 连续推送取消旧检查。 +- 拆分:`cloud_agent_runtime.go`、`cloud_agent_tools.go`、`cloud_agent_pi_coordinator.go`、`workflow_provider.go`、`repository.go`、Agent 面板与输入区按职责拆成多个文件,逐声明校验为纯移动;新文件带职责说明。 +- [x] 后端 `go vet ./...`、`go test ./...` 全部通过(依赖 Postgres/Redis 的 7 个用例本地跳过,CI 会执行);新增取消等待、插件包按需解压与本机平台产物回归用例。 +- [x] 前端 `tsc`、lint、改动文件格式检查与 2103 项测试通过。 +- [ ] 真实页面:删除被画布引用的素材应直接成功;画布再次打开时断链由修复流程处理;共享给其他素材的文件仍可访问。 +- [ ] 推送后确认 GitHub Actions 后端检查耗时下降,镜像构建与检查并行、检查失败时不会推广 latest / 版本标签。 + ## 后端统一日志与降噪 - 已实现:新增 `backend/internal/logging`,统一接管 slog、标准库 `log`、GORM 与 Gin 访问日志,级别与格式由 `CANVAS_LOG_LEVEL` / `CANVAS_LOG_FORMAT` 控制。`info` 下不再输出成功的查询类请求、成功 SQL、`[Agent] message_end` 等流式事件及常规画布自动保存;4xx/5xx、慢请求、慢 SQL、SQL 失败和 panic 仍会记录,SQL 不带参数值。 diff --git a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx index 90d46c74c..27c9d9e5d 100644 --- a/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx +++ b/web/src/components/canvas/art-critique/ai-art-critique-modal.tsx @@ -1,39 +1,32 @@ -import { canvasThemes } from "@/lib/canvas-theme"; -import { Button, Tag } from "antd"; +import { ART_CRITIQUE_STAGE_PROGRESS, CritiqueOverlay, ArtCritiqueProgress, IssueDetailView, ReportSummary, OptionCard, FilterChip, IssueCard } from "./ai-art-critique-report"; +import { resolveImageUrl } from "@/services/image-storage"; +import { repairIssueTarget, layoutArtCritiqueLabels, ART_CRITIQUE_CATEGORY_COLORS } from "@/lib/art-critique/annotation"; +import { executeArtCritique } from "@/services/art-critique-execution"; import { AppModal } from "@/components/ui/product/app-modal"; +import { Sparkles, X, Image as ImageIcon, FileText, CheckCircle2, LoaderCircle, RefreshCw } from "lucide-react"; +import { IconButton } from "@/components/ui/base/buttons"; import { EmptyState } from "@/components/ui/product/empty-state"; -import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, FileText, Image as ImageIcon, LoaderCircle, RefreshCw, Sparkles, Target, X } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { CreativeQuoteCard } from "@/components/creation/creative-agent-cards"; +import { Callout } from "@/components/ui/product/callout"; +import { Button } from "antd"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { useMemo, useRef, useState, useEffect } from "react"; import { - ART_CRITIQUE_SCHEMA_VERSION, ART_CRITIQUE_PLUGIN_ID, - artCritiqueCategoryLabel, - artCritiqueSeverityLabel, - artCritiqueStageLabel, artCritiqueSourceFingerprint, createDefaultArtCritiqueState, isArtCritiqueImageInput, type ArtCritiqueCategory, - type ArtCritiqueIssue, type ArtCritiqueNodeState, - type ArtCritiqueOption, - type ArtCritiquePipelineStage, - type ArtCritiqueReport, -} from "@/lib/art-critique/contracts"; -import { ART_CRITIQUE_CATEGORY_COLORS, ART_CRITIQUE_SEVERITY_COLORS, layoutArtCritiqueLabels, repairIssueTarget, targetBounds } from "@/lib/art-critique/annotation"; -import { executeArtCritique } from "@/services/art-critique-execution"; + type ArtCritiquePipelineStage, ART_CRITIQUE_SCHEMA_VERSION, type ArtCritiqueReport, artCritiqueStageLabel, artCritiqueCategoryLabel } from "@/lib/art-critique/contracts"; import { ApprovedToolExecution, type ApprovedToolExecutionState } from "@/services/approved-tool-execution"; -import { CreativeQuoteCard } from "@/components/creation/creative-agent-cards"; import { getActiveUserScope } from "@/lib/user-scope"; -import { useCopyText } from "@/hooks/use-copy-text"; -import { resolveImageUrl } from "@/services/image-storage"; import { modelOptionLabel, useEffectiveConfig } from "@/stores/use-config-store"; import { usePluginStore } from "@/stores/use-plugin-store"; import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store"; import type { CanvasNodeData } from "@/types/canvas"; -import { IconButton } from "@/components/ui/base/buttons"; -import { Callout } from "@/components/ui/product/callout"; +export { ART_CRITIQUE_STAGE_PROGRESS } from "./ai-art-critique-report"; type AiArtCritiqueModalProps = { startRequestId?: string; @@ -47,20 +40,9 @@ type AiArtCritiqueModalProps = { }; const CATEGORIES: ArtCritiqueCategory[] = ["composition", "color", "lighting", "proportion", "other"]; -const ART_CRITIQUE_STAGE_PROGRESS: Record = { - preparing: 8, - scene: 22, - reviewing: 52, - aggregating: 66, - grounding: 78, - verifying: 90, - annotating: 97, - completed: 100, - failed: 100, -}; -type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; +export type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes]; type AiArtCritiqueView = "overview" | "detail"; -type ArtCritiquePromptStatus = "ready" | "pending" | "unavailable"; +export type ArtCritiquePromptStatus = "ready" | "pending" | "unavailable"; export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdateState, startRequestId, restartRequested, onRunningChange }: AiArtCritiqueModalProps) { const theme = canvasThemes[useActiveTheme()]; @@ -504,416 +486,3 @@ export function AiArtCritiqueModal({ node, upstreamNodes, open, onClose, onUpdat ); } - -function ArtCritiqueProgress({ stage, theme }: { stage: ArtCritiquePipelineStage; theme: CanvasTheme }) { - const percent = ART_CRITIQUE_STAGE_PROGRESS[stage]; - const detail = stage === "reviewing" ? "场景路由与多个 Reviewer 正在并行检查画面" : stage === "verifying" ? "正在并行复核问题并生成 AI 修改提示词" : "正在按阶段生成批改报告"; - return ( -
-
- - - - {percent}% - -
-
-
-
-
- {detail} -
-
- ); -} - -function ReportSummary({ report, theme, isDraft }: { report: NonNullable; theme: CanvasTheme; isDraft?: boolean }) { - const summary = report.summary || "这张图暂时没有整体总结。"; - const hasLongSummary = summary.length > 120; - return ( -
-
- {isDraft ? ( -
-

{summary}

- {hasLongSummary ? ( -
- - 查看完整判断 - -

- {summary} -

-
- ) : null} - {report.pipelineWarnings?.length ? ( -
- - 本次分析有部分步骤降级 - -
    - {report.pipelineWarnings.map((warning) => ( -
  • {warning}
  • - ))} -
-
- ) : null} - {report.strengths.length ? ( -
- - 画面已有的优点 - - {report.strengths.length} 项 - - -
    - {report.strengths.slice(0, 3).map((item) => ( -
  • {item}
  • - ))} -
- {report.strengths.length > 3 ? ( -
- 还有 {report.strengths.length - 3} 项优点 -
- ) : null} -
- ) : null} -
- ); -} - -function IssueCard({ issue, index, selected, theme, onClick, onHover }: { issue: ArtCritiqueIssue; index: number; selected: boolean; theme: CanvasTheme; onClick: () => void; onHover: (issueId: string | null) => void }) { - const color = ART_CRITIQUE_SEVERITY_COLORS[issue.severity]; - return ( - - ); -} - -function IssueDetailView({ - issue, - issueIndex, - total, - isDraft, - promptStatus, - theme, - onBack, - onPrevious, - onNext, -}: { - issue: ArtCritiqueIssue; - issueIndex: number; - total: number; - isDraft?: boolean; - promptStatus: ArtCritiquePromptStatus; - theme: CanvasTheme; - onBack: () => void; - onPrevious: () => void; - onNext: () => void; -}) { - const targetSourceLabel = isDraft ? "定位中" : issue.targetSource === "reference" ? "参考区域" : issue.targetSource === "model" ? "模型定位" : issue.target.type === "global" ? "全局范围" : "局部区域"; - const severityColor = ART_CRITIQUE_SEVERITY_COLORS[issue.severity]; - const copyText = useCopyText(); - return ( -
-
- - - 问题 {issueIndex + 1} / {total} - -
- -
-
-
-

{issue.title}

-
- {artCritiqueCategoryLabel(issue.category)} -
-
-
- AI 判断 -
-

{issue.explanation}

-
- {issue.targetDescription ? ( -
- 定位参考: - {issue.targetDescription} - - {targetSourceLabel} - -
- ) : null} -
- -
-
-
-
- {promptStatus === "ready" ? ( - - ) : ( - - )} -
- {promptStatus === "pending" ? ( -
- 定位完成后,AI 会结合问题区域生成可直接用于局部编辑的提示词。 -
- ) : null} - {promptStatus === "unavailable" ? ( -
- AI 修改提示词未生成,请重新批改后重试。 -
- ) : null} -
- 目标: - {issue.suggestion.goal} -
- {issue.suggestion.actions.length ? ( -
-
- 建议动作 -
-
    - {issue.suggestion.actions.map((item, index) => ( -
  1. - - {index + 1} - - {item} -
  2. - ))} -
-
- ) : null} - {issue.suggestion.preserve.length ? ( -
-
- 需要保留 -
-
    - {issue.suggestion.preserve.map((item) => ( -
  • {item}
  • - ))} -
-
- ) : null} -
- 预期效果: - {issue.suggestion.expectedEffect} -
-
- -
- - -
-
- ); -} - -function OptionCard({ option, theme }: { option: ArtCritiqueOption; theme: CanvasTheme }) { - return ( -
-
-
-

- {option.explanation} -

- {option.suggestion.actions.length ? ( -
    - {option.suggestion.actions.slice(0, 3).map((action) => ( -
  • {action}
  • - ))} -
- ) : null} -
- ); -} - -function FilterChip({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) { - return ( - - ); -} - -function CritiqueOverlay({ - issues, - issueNumberById, - selectedIssueId, - labelPlacements, - onSelect, -}: { - issues: ArtCritiqueIssue[]; - issueNumberById: ReadonlyMap; - selectedIssueId: string | null; - labelPlacements: Array<{ x: number; y: number }>; - onSelect: (id: string) => void; -}) { - return ( - - AI 批改标注 - {issues.map((issue, index) => { - const renderIssue = repairIssueTarget(issue); - const issueNumber = issueNumberById.get(renderIssue.id) || index + 1; - const color = ART_CRITIQUE_SEVERITY_COLORS[renderIssue.severity]; - const selected = renderIssue.id === selectedIssueId; - const dimmed = Boolean(selectedIssueId) && !selected; - const approximate = renderIssue.target.type !== "box"; - const uncertain = renderIssue.targetSource === "reference" || renderIssue.verification?.verdict === "uncertain"; - const label = labelPlacements[index] || { x: 0.04, y: 0.06 }; - const bounds = targetBounds(renderIssue.target); - const labelX = label.x * 100; - const labelY = label.y * 100; - return ( - event.stopPropagation()} - onClick={() => onSelect(renderIssue.id)} - onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - onSelect(renderIssue.id); - } - }} - > - - - - {issueNumber} - - {`${issueNumber}:${renderIssue.title}`} - - ); - })} - - ); -} diff --git a/web/src/components/canvas/art-critique/ai-art-critique-report.tsx b/web/src/components/canvas/art-critique/ai-art-critique-report.tsx new file mode 100644 index 000000000..c333f7880 --- /dev/null +++ b/web/src/components/canvas/art-critique/ai-art-critique-report.tsx @@ -0,0 +1,434 @@ +// AI 美术评审报告视图:进度、摘要、问题卡片与详情、修改方案、筛选与画面标注叠层。 + +import { artCritiqueCategoryLabel, type ArtCritiqueIssue, type ArtCritiqueNodeState, type ArtCritiqueOption, type ArtCritiquePipelineStage, artCritiqueSeverityLabel, artCritiqueStageLabel } from "@/lib/art-critique/contracts"; +import { ArrowLeft, ArrowRight, CheckCircle2, ChevronDown, ChevronRight, Copy, LoaderCircle, Sparkles, Target } from "lucide-react"; +import { ART_CRITIQUE_CATEGORY_COLORS, ART_CRITIQUE_SEVERITY_COLORS, repairIssueTarget, targetBounds } from "@/lib/art-critique/annotation"; +import { useCopyText } from "@/hooks/use-copy-text"; +import { Button, Tag } from "antd"; +import type { ArtCritiquePromptStatus, CanvasTheme } from "./ai-art-critique-modal"; + +export const ART_CRITIQUE_STAGE_PROGRESS: Record = { + preparing: 8, + scene: 22, + reviewing: 52, + aggregating: 66, + grounding: 78, + verifying: 90, + annotating: 97, + completed: 100, + failed: 100, +}; + + +export function ArtCritiqueProgress({ stage, theme }: { stage: ArtCritiquePipelineStage; theme: CanvasTheme }) { + const percent = ART_CRITIQUE_STAGE_PROGRESS[stage]; + const detail = stage === "reviewing" ? "场景路由与多个 Reviewer 正在并行检查画面" : stage === "verifying" ? "正在并行复核问题并生成 AI 修改提示词" : "正在按阶段生成批改报告"; + return ( +
+
+ + + + {percent}% + +
+
+
+
+
+ {detail} +
+
+ ); +} + +export function ReportSummary({ report, theme, isDraft }: { report: NonNullable; theme: CanvasTheme; isDraft?: boolean }) { + const summary = report.summary || "这张图暂时没有整体总结。"; + const hasLongSummary = summary.length > 120; + return ( +
+
+ {isDraft ? ( +
+

{summary}

+ {hasLongSummary ? ( +
+ + 查看完整判断 + +

+ {summary} +

+
+ ) : null} + {report.pipelineWarnings?.length ? ( +
+ + 本次分析有部分步骤降级 + +
    + {report.pipelineWarnings.map((warning) => ( +
  • {warning}
  • + ))} +
+
+ ) : null} + {report.strengths.length ? ( +
+ + 画面已有的优点 + + {report.strengths.length} 项 + + +
    + {report.strengths.slice(0, 3).map((item) => ( +
  • {item}
  • + ))} +
+ {report.strengths.length > 3 ? ( +
+ 还有 {report.strengths.length - 3} 项优点 +
+ ) : null} +
+ ) : null} +
+ ); +} + +export function IssueCard({ issue, index, selected, theme, onClick, onHover }: { issue: ArtCritiqueIssue; index: number; selected: boolean; theme: CanvasTheme; onClick: () => void; onHover: (issueId: string | null) => void }) { + const color = ART_CRITIQUE_SEVERITY_COLORS[issue.severity]; + return ( + + ); +} + +export function IssueDetailView({ + issue, + issueIndex, + total, + isDraft, + promptStatus, + theme, + onBack, + onPrevious, + onNext, +}: { + issue: ArtCritiqueIssue; + issueIndex: number; + total: number; + isDraft?: boolean; + promptStatus: ArtCritiquePromptStatus; + theme: CanvasTheme; + onBack: () => void; + onPrevious: () => void; + onNext: () => void; +}) { + const targetSourceLabel = isDraft ? "定位中" : issue.targetSource === "reference" ? "参考区域" : issue.targetSource === "model" ? "模型定位" : issue.target.type === "global" ? "全局范围" : "局部区域"; + const severityColor = ART_CRITIQUE_SEVERITY_COLORS[issue.severity]; + const copyText = useCopyText(); + return ( +
+
+ + + 问题 {issueIndex + 1} / {total} + +
+ +
+
+
+

{issue.title}

+
+ {artCritiqueCategoryLabel(issue.category)} +
+
+
+ AI 判断 +
+

{issue.explanation}

+
+ {issue.targetDescription ? ( +
+ 定位参考: + {issue.targetDescription} + + {targetSourceLabel} + +
+ ) : null} +
+ +
+
+
+
+ {promptStatus === "ready" ? ( + + ) : ( + + )} +
+ {promptStatus === "pending" ? ( +
+ 定位完成后,AI 会结合问题区域生成可直接用于局部编辑的提示词。 +
+ ) : null} + {promptStatus === "unavailable" ? ( +
+ AI 修改提示词未生成,请重新批改后重试。 +
+ ) : null} +
+ 目标: + {issue.suggestion.goal} +
+ {issue.suggestion.actions.length ? ( +
+
+ 建议动作 +
+
    + {issue.suggestion.actions.map((item, index) => ( +
  1. + + {index + 1} + + {item} +
  2. + ))} +
+
+ ) : null} + {issue.suggestion.preserve.length ? ( +
+
+ 需要保留 +
+
    + {issue.suggestion.preserve.map((item) => ( +
  • {item}
  • + ))} +
+
+ ) : null} +
+ 预期效果: + {issue.suggestion.expectedEffect} +
+
+ +
+ + +
+
+ ); +} + +export function OptionCard({ option, theme }: { option: ArtCritiqueOption; theme: CanvasTheme }) { + return ( +
+
+
+

+ {option.explanation} +

+ {option.suggestion.actions.length ? ( +
    + {option.suggestion.actions.slice(0, 3).map((action) => ( +
  • {action}
  • + ))} +
+ ) : null} +
+ ); +} + +export function FilterChip({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) { + return ( + + ); +} + +export function CritiqueOverlay({ + issues, + issueNumberById, + selectedIssueId, + labelPlacements, + onSelect, +}: { + issues: ArtCritiqueIssue[]; + issueNumberById: ReadonlyMap; + selectedIssueId: string | null; + labelPlacements: Array<{ x: number; y: number }>; + onSelect: (id: string) => void; +}) { + return ( + + AI 批改标注 + {issues.map((issue, index) => { + const renderIssue = repairIssueTarget(issue); + const issueNumber = issueNumberById.get(renderIssue.id) || index + 1; + const color = ART_CRITIQUE_SEVERITY_COLORS[renderIssue.severity]; + const selected = renderIssue.id === selectedIssueId; + const dimmed = Boolean(selectedIssueId) && !selected; + const approximate = renderIssue.target.type !== "box"; + const uncertain = renderIssue.targetSource === "reference" || renderIssue.verification?.verdict === "uncertain"; + const label = labelPlacements[index] || { x: 0.04, y: 0.06 }; + const bounds = targetBounds(renderIssue.target); + const labelX = label.x * 100; + const labelY = label.y * 100; + return ( + event.stopPropagation()} + onClick={() => onSelect(renderIssue.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(renderIssue.id); + } + }} + > + + + + {issueNumber} + + {`${issueNumber}:${renderIssue.title}`} + + ); + })} + + ); +} diff --git a/web/src/components/canvas/canvas-cloud-agent-attachments.ts b/web/src/components/canvas/canvas-cloud-agent-attachments.ts new file mode 100644 index 000000000..2cbcb38ed --- /dev/null +++ b/web/src/components/canvas/canvas-cloud-agent-attachments.ts @@ -0,0 +1,21 @@ +// 画布 Agent 对话附件:类型定义与「附件 → @ 引用」转换。 +// +// 输入区(composer)与消息渲染(chat-ui)都依赖它,单独成模块以避免两者互相 import。 + +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; + +export type CloudAgentChatAttachment = { id: string; name: string; url: string }; + +/** 附件以 @[attachment:] 引用进入提示词,编号按附件顺序从「图片1」开始。 */ +export function agentAttachmentReferences(attachments: CloudAgentChatAttachment[]): CanvasResourceReference[] { + return attachments.map((item, index) => ({ + id: `attachment:${item.id}`, + nodeId: "", + kind: "image", + label: `图片${index + 1}`, + title: item.name || `图片${index + 1}`, + previewUrl: item.url, + active: true, + mentionToken: `@[attachment:${item.id}]`, + })); +} diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index dfe9f6687..160e1bf89 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -1,67 +1,25 @@ import { agentCanvasActions, agentCanvasActionLabel } from "@/lib/canvas/agent-canvas-actions"; import { Button } from "antd"; -import { Tooltip } from "@/components/ui/base/tooltip"; -import { - useCallback, - useEffect, - useId, - useMemo, - useRef, - useState, - type ClipboardEvent as ReactClipboardEvent, - type CSSProperties, - type KeyboardEvent as ReactKeyboardEvent, - type PointerEvent as ReactPointerEvent, - type ReactNode, - type SyntheticEvent, -} from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState, type CSSProperties, type ReactNode, type SyntheticEvent } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { - ArrowLeft, - ArrowUp, - AtSign, - Bookmark, - CheckCircle2, - ChevronDown, - ChevronUp, - CircleAlert, - CircleDot, - Clapperboard, - Eye, - HelpCircle, - ImagePlus, - Layers3, - List, - ListChecks, - LoaderCircle, - Palette, - Pencil, - Plus, - RotateCcw, - Shapes, - Share2, - ShoppingBag, - Sparkles, - Square, - Wrench, - X, - XCircle, -} from "lucide-react"; +import { CheckCircle2, ChevronDown, ChevronUp, CircleAlert, CircleDot, Eye, HelpCircle, List, ListChecks, LoaderCircle, Pencil, Plus, RotateCcw, Sparkles, Wrench, XCircle } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; -import { WorkingGlow } from "@/components/ai/working-indicator"; -import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import type { Components } from "streamdown"; -import type { Skill, SkillPreset } from "@/services/api/skills"; -import { buildSkillMentionReferences } from "@/services/skill-runtime"; import { agentToolCategory, agentToolCategoryLabel, agentToolErrorClassLabel, agentToolName, agentToolRetryLabel, agentToolStatus, friendlyAgentToolSummary, type AgentToolCategory } from "@/lib/canvas/agent-tool-presentation"; import { agentOperationCategory, agentOperationFailed, agentOperationSegmentLabel } from "@/lib/canvas/agent-operation-feed"; import { agentToolRetry, type AgentToolRetryAttempt } from "@/lib/canvas/agent-tool-retry"; +import { AgentImagePreview } from "./canvas-cloud-agent-composer"; + +// 输入区已拆到 canvas-cloud-agent-composer.tsx;附件类型与引用转换在 canvas-cloud-agent-attachments.ts。 +// 这里保留再导出,既有 import 路径保持可用。 +import { agentAttachmentReferences, type CloudAgentChatAttachment } from "./canvas-cloud-agent-attachments"; +export { agentAttachmentReferences, type CloudAgentChatAttachment } from "./canvas-cloud-agent-attachments"; +export { AGENT_SCENE_DEFS, AgentChatComposer, AgentImagePreview, AgentSceneCapsules, type AgentSceneBucket } from "./canvas-cloud-agent-composer"; -export type CloudAgentChatAttachment = { id: string; name: string; url: string }; type CloudAgentOperationImpact = { operationCount: number; affectedNodeCount: number; @@ -142,7 +100,6 @@ export function agentControlMessage(id: string, text: string, meta?: string): Cl return { id, role: "system", text, meta }; } - const AGENT_FORM_FIELD_LABELS: Record = { genre: "题材", aspectRatio: "画幅", @@ -253,13 +210,6 @@ export function extractCloudAgentQuickActions(text: string): CloudAgentQuickActi } const WORKING_TEXT = "正在处理"; -const MIN_AGENT_PROMPT_HEIGHT = 60; -const MAX_AGENT_PROMPT_HEIGHT = 240; - -function clampAgentPromptHeight(height: number) { - return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height))); -} - const AGENT_NODE_LINK_PREFIX = "#agent-node:"; const AGENT_NODE_TYPE_LABELS: Record = { image: "图片节点", @@ -1019,7 +969,17 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false } displayAnswers[field.id] = option?.label || value; } const fieldTitles = Object.fromEntries(fields.map((field) => [field.id, field.title])); - onAnswer(JSON.stringify({ type: "form_answer", questionId: question.questionId, answers, displayAnswers, fieldTitles, skippedFields: fields.filter((field) => !String(answers[field.id] || "").trim()).map((field) => field.id), useRecommendedDefaults: useDefaults })); + onAnswer( + JSON.stringify({ + type: "form_answer", + questionId: question.questionId, + answers, + displayAnswers, + fieldTitles, + skippedFields: fields.filter((field) => !String(answers[field.id] || "").trim()).map((field) => field.id), + useRecommendedDefaults: useDefaults, + }), + ); }; const stop = (event: SyntheticEvent) => { @@ -1201,509 +1161,6 @@ export function AgentQuestionBar({ question, theme, onAnswer, disabled = false } ); } -/** - * 场景起步胶囊:把「我大概想做 X」一步翻译成一组技能。 - * 两组来源,都不限剧典技能: - * 1) 配方组——只读消费 GET /skills/presets(随二进制内置的手工策展配方) - * 2) 常用组——用户已装(其中可含已收藏)及自建的技能,按常用度排序(含官方种子库与自定义) - * 选择只作用于本会话;缺失的技能会持久安装到当前用户的技能库。 - * 挂上之后具体用哪张卡由 Agent 在任务里检索判断,胶囊只负责"把对的技能送到手边"。 - */ -export type AgentSceneBucket = { - key: string; - label: string; - presets: SkillPreset[]; - skills: Skill[]; -}; - -/** 场景分类:与 presets.json 的 scene 字段、技能的 tag 字段共用同一套 key。 */ -export const AGENT_SCENE_DEFS: Array<{ key: string; label: string; icon: typeof Sparkles }> = [ - { key: "frequent", label: "我的常用", icon: Bookmark }, - { key: "drama", label: "短剧故事", icon: Clapperboard }, - { key: "ecommerce", label: "广告电商", icon: ShoppingBag }, - { key: "creative", label: "视觉创意", icon: Palette }, - { key: "social", label: "传播社媒", icon: Share2 }, - { key: "others", label: "其他", icon: Shapes }, -]; - -export function AgentSceneCapsules({ - buckets, - installedIds, - theme, - disabled = false, - onPick, - onPickSkill, -}: { - buckets: AgentSceneBucket[]; - installedIds: Set; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - disabled?: boolean; - onPick: (preset: SkillPreset) => void; - onPickSkill: (skill: Skill) => void; -}) { - // 始终只占一排:默认显示场景分类,点某个场景后在同一排内就地切换内容。 - const [activeKey, setActiveKey] = useState(null); - const capsuleClass = "agent-scene-capsule shrink-0"; - const stop = { - onMouseDown: (event: { stopPropagation(): void }) => event.stopPropagation(), - onPointerDown: (event: { stopPropagation(): void }) => event.stopPropagation(), - }; - const visible = buckets.filter((bucket) => bucket.presets.length + bucket.skills.length > 0); - const active = activeKey ? visible.find((bucket) => bucket.key === activeKey) || null : null; - if (!visible.length) return null; - return ( -
-
-
-
- {active ? ( - <> - - {active.presets.map((preset) => { - const missing = preset.skillIds.filter((id) => !installedIds.has(id)).length; - return ( - - ); - })} - {active.skills.map((skill) => { - const owned = skill.isOwner ? "自建" : skill.isLike ? "已收藏" : installedIds.has(skill.skillId) ? "已装" : "待装"; - return ( - - ); - })} - - ) : ( - visible.map((bucket) => { - const count = bucket.presets.length + bucket.skills.length; - const Icon = AGENT_SCENE_DEFS.find((definition) => definition.key === bucket.key)?.icon || Shapes; - return ( - - ); - }) - )} -
-

仅本会话生效 · 缺失技能将加入技能库 · Agent 按任务调用

-
- ); -} - -export function AgentChatComposer({ - prompt, - attachments = [], - disabled, - sending, - running, - placeholder, - theme, - onPromptChange, - onSubmit, - onAddFiles, - onRemoveAttachment, - left, - submitAccessory, - onStop, - stopping, - references = [], - slashSkills, - includeAssetLibrary, -}: { - prompt: string; - attachments?: CloudAgentChatAttachment[]; - disabled?: boolean; - sending?: boolean; - running?: boolean; - placeholder: string; - theme: (typeof canvasThemes)[keyof typeof canvasThemes]; - onPromptChange: (value: string) => void; - onSubmit: () => void; - onStop?: () => void | Promise; - stopping?: boolean; - onAddFiles?: (files: FileList | File[] | null) => void | Promise; - onRemoveAttachment?: (id: string) => void; - left?: ReactNode; - /** 发送按钮左侧的附属控件,例如上下文用量环。 */ - submitAccessory?: ReactNode; - /** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */ - references?: CanvasResourceReference[]; - /** 供「/」弹出的技能候选(可选) */ - slashSkills?: Skill[]; - /** 是否在「@」候选里包含素材库资源 */ - includeAssetLibrary?: boolean; -}) { - const fileInputRef = useRef(null); - const [slash, setSlash] = useState<{ start: number; query: string } | null>(null); - const [slashIndex, setSlashIndex] = useState(0); - const [previewAttachment, setPreviewAttachment] = useState(null); - const [promptHeight, setPromptHeight] = useState(MIN_AGENT_PROMPT_HEIGHT); - const promptResizeRef = useRef<{ startY: number; startHeight: number } | null>(null); - const manualPromptHeightRef = useRef(null); - const availableSlashSkills = slashSkills ?? []; - const slashCandidates = useMemo(() => { - const query = slash?.query.trim().toLocaleLowerCase() || ""; - if (!query) return availableSlashSkills; - return availableSlashSkills.filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(query)); - }, [availableSlashSkills, slash?.query]); - const attachmentReferences = useMemo(() => agentAttachmentReferences(attachments), [attachments]); - const skillReferences = useMemo(() => buildSkillMentionReferences(availableSlashSkills), [availableSlashSkills]); - const composerReferences = useMemo(() => [...references, ...skillReferences, ...attachmentReferences].filter((reference, index, all) => all.findIndex((item) => item.id === reference.id) === index), [attachmentReferences, references, skillReferences]); - const canStop = Boolean(running && onStop); - const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); - const reducedMotion = useReducedMotion(); - const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(slashCandidates.length - 1, 0)); - - const handlePromptContentSizeChange = useCallback((naturalHeight: number) => { - const nextHeight = clampAgentPromptHeight(naturalHeight); - // 用户开始拖动后,面板高度由用户掌控;超出部分交给内部滚动区, - // 避免输入新内容时又把手动缩小的面板强行撑开。 - setPromptHeight((currentHeight) => (manualPromptHeightRef.current === null ? nextHeight : currentHeight)); - }, []); - - useEffect(() => { - if (prompt.trim()) return; - manualPromptHeightRef.current = null; - setPromptHeight(MIN_AGENT_PROMPT_HEIGHT); - }, [prompt]); - - const startPromptResize = (event: ReactPointerEvent) => { - if (disabled) return; - event.preventDefault(); - manualPromptHeightRef.current = promptHeight; - promptResizeRef.current = { startY: event.clientY, startHeight: promptHeight }; - event.currentTarget.setPointerCapture(event.pointerId); - }; - - const resizePrompt = (event: ReactPointerEvent) => { - const resize = promptResizeRef.current; - if (!resize) return; - setPromptHeight(clampAgentPromptHeight(resize.startHeight + resize.startY - event.clientY)); - }; - - const finishPromptResize = (event?: ReactPointerEvent) => { - promptResizeRef.current = null; - if (event?.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); - }; - - const resizePromptByKeyboard = (event: ReactKeyboardEvent) => { - if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; - event.preventDefault(); - manualPromptHeightRef.current = promptHeight; - setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20))); - }; - - // 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。 - const handlePromptChange = (value: string) => { - onPromptChange(value); - const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value); - if (match && availableSlashSkills.length) { - const next = { start: match.index + match[1].length, query: match[2] }; - setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next)); - setSlashIndex(0); - } else if (slash) { - setSlash(null); - } - }; - - const applySlashSkill = (skill: Skill) => { - const token = `@[skill:${skill.skillId}] `; - // 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。 - const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token; - setSlash(null); - setSlashIndex(0); - onPromptChange(next); - }; - - // slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到) - const handleSlashKeyCapture = (event: ReactKeyboardEvent) => { - if (!slash || !slashCandidates.length) return; - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "ArrowDown") { - event.preventDefault(); - event.stopPropagation(); - setSlashIndex((index) => Math.min(index + 1, slashCandidates.length - 1)); - } else if (event.key === "ArrowUp") { - event.preventDefault(); - event.stopPropagation(); - setSlashIndex((index) => Math.max(index - 1, 0)); - } else if (event.key === "Enter" || event.key === "Tab") { - event.preventDefault(); - event.stopPropagation(); - applySlashSkill(slashCandidates[activeSlashIndex]); - } else if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - setSlash(null); - } - }; - - // 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片) - const handlePasteCapture = (event: ReactClipboardEvent) => { - if (!onAddFiles) return; - const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); - if (!images.length) return; - event.preventDefault(); - event.stopPropagation(); - void onAddFiles(images); - }; - - const insertAttachmentMention = (item: CloudAgentChatAttachment) => { - const token = `@[attachment:${item.id}] `; - if (prompt.includes(`@[attachment:${item.id}]`)) return; - onPromptChange(prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token); - }; - - return ( -
event.stopPropagation()}> -
- {sending && !reducedMotion ? : null} - {attachments.length ? ( -
- {attachments.map((item, index) => ( -
- -
- - {onRemoveAttachment ? ( - - ) : null} -
-
- ))} -
- ) : null} -
- -
- { - if (canSubmit) onSubmit(); - }} - onContentSizeChange={handlePromptContentSizeChange} - className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45" - containerClassName="min-h-[60px] h-full" - style={{ color: theme.node.text }} - placeholder={placeholder} - aria-label="Agent 输入" - /> -
- {slash && slashCandidates.length ? ( -
event.preventDefault()} - > - {slashCandidates.map((skill, index) => ( - - ))} -
- ) : null} -
-
-
- {onAddFiles ? ( - <> - { - void onAddFiles(event.target.files); - event.target.value = ""; - }} - /> - -
-
- {submitAccessory} - {canStop ? ( - void onStop?.()} - whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined} - whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined} - animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }} - transition={{ type: "spring", stiffness: 420, damping: 24 }} - className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" - style={{ background: theme.accent.danger, color: theme.accent.onPrimary }} - > - {stopping ? : } - - ) : null} - onSubmit()} - whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined} - whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined} - animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }} - transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }} - data-icon-only - className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" - style={{ - background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface, - color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted, - }} - > - - {sending ? : } - - -
-
-
- {previewAttachment ? setPreviewAttachment(null)} /> : null} -
- ); -} export function AgentPanelTabs({ value, @@ -1776,30 +1233,6 @@ function AgentMessageAttachments({ attachments }: { attachments: CloudAgentChatA ); } -export function AgentImagePreview({ attachment, onClose }: { attachment: CloudAgentChatAttachment; onClose: () => void }) { - return ( -
- {attachment.name} event.stopPropagation()} /> - -
- ); -} - -function agentAttachmentReferences(attachments: CloudAgentChatAttachment[]): CanvasResourceReference[] { - return attachments.map((item, index) => ({ - id: `attachment:${item.id}`, - nodeId: "", - kind: "image", - label: `图片${index + 1}`, - title: item.name || `图片${index + 1}`, - previewUrl: item.url, - active: true, - mentionToken: `@[attachment:${item.id}]`, - })); -} - function toolCardState(title: string, text: string, detail?: unknown) { const status = agentToolStatus(title, text, detail); // 失败时优先显示稳定归类("参数不符合契约"/"画布状态已变化"/"模型输出问题"…): diff --git a/web/src/components/canvas/canvas-cloud-agent-composer.tsx b/web/src/components/canvas/canvas-cloud-agent-composer.tsx new file mode 100644 index 000000000..251d242ac --- /dev/null +++ b/web/src/components/canvas/canvas-cloud-agent-composer.tsx @@ -0,0 +1,539 @@ +// 画布 Agent 输入区:场景胶囊、输入框(附件、@ 引用、/ 技能)、发送/插话/停止按钮与图片预览。 +// +// 运行中输入框保持可用(产品约定,勿收紧):发送走插话,停止由 running 驱动,两者互不排斥。 + +import type { Skill, SkillPreset } from "@/services/api/skills"; +import { ArrowLeft, ArrowUp, AtSign, Bookmark, Clapperboard, ImagePlus, Layers3, LoaderCircle, Palette, Shapes, Share2, ShoppingBag, Sparkles, Square, X } from "lucide-react"; +import { canvasThemes } from "@/lib/canvas-theme"; +import { type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import { buildSkillMentionReferences } from "@/services/skill-runtime"; +import { motion, useReducedMotion } from "motion/react"; +import { WorkingGlow } from "@/components/ai/working-indicator"; +import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; +import { Tooltip } from "@/components/ui/base/tooltip"; +import { Button } from "antd"; +import { agentAttachmentReferences, type CloudAgentChatAttachment } from "./canvas-cloud-agent-attachments"; + +export const MIN_AGENT_PROMPT_HEIGHT = 60; + +export const MAX_AGENT_PROMPT_HEIGHT = 240; + +export function clampAgentPromptHeight(height: number) { + return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height))); +} + +/** + * 场景起步胶囊:把「我大概想做 X」一步翻译成一组技能。 + * 两组来源,都不限剧典技能: + * 1) 配方组——只读消费 GET /skills/presets(随二进制内置的手工策展配方) + * 2) 常用组——用户已装(其中可含已收藏)及自建的技能,按常用度排序(含官方种子库与自定义) + * 选择只作用于本会话;缺失的技能会持久安装到当前用户的技能库。 + * 挂上之后具体用哪张卡由 Agent 在任务里检索判断,胶囊只负责"把对的技能送到手边"。 + */ +export type AgentSceneBucket = { + key: string; + label: string; + presets: SkillPreset[]; + skills: Skill[]; +}; + +/** 场景分类:与 presets.json 的 scene 字段、技能的 tag 字段共用同一套 key。 */ +export const AGENT_SCENE_DEFS: Array<{ key: string; label: string; icon: typeof Sparkles }> = [ + { key: "frequent", label: "我的常用", icon: Bookmark }, + { key: "drama", label: "短剧故事", icon: Clapperboard }, + { key: "ecommerce", label: "广告电商", icon: ShoppingBag }, + { key: "creative", label: "视觉创意", icon: Palette }, + { key: "social", label: "传播社媒", icon: Share2 }, + { key: "others", label: "其他", icon: Shapes }, +]; + +export function AgentSceneCapsules({ + buckets, + installedIds, + theme, + disabled = false, + onPick, + onPickSkill, +}: { + buckets: AgentSceneBucket[]; + installedIds: Set; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + disabled?: boolean; + onPick: (preset: SkillPreset) => void; + onPickSkill: (skill: Skill) => void; +}) { + // 始终只占一排:默认显示场景分类,点某个场景后在同一排内就地切换内容。 + const [activeKey, setActiveKey] = useState(null); + const capsuleClass = "agent-scene-capsule shrink-0"; + const stop = { + onMouseDown: (event: { stopPropagation(): void }) => event.stopPropagation(), + onPointerDown: (event: { stopPropagation(): void }) => event.stopPropagation(), + }; + const visible = buckets.filter((bucket) => bucket.presets.length + bucket.skills.length > 0); + const active = activeKey ? visible.find((bucket) => bucket.key === activeKey) || null : null; + if (!visible.length) return null; + return ( +
+
+
+
+ {active ? ( + <> + + {active.presets.map((preset) => { + const missing = preset.skillIds.filter((id) => !installedIds.has(id)).length; + return ( + + ); + })} + {active.skills.map((skill) => { + const owned = skill.isOwner ? "自建" : skill.isLike ? "已收藏" : installedIds.has(skill.skillId) ? "已装" : "待装"; + return ( + + ); + })} + + ) : ( + visible.map((bucket) => { + const count = bucket.presets.length + bucket.skills.length; + const Icon = AGENT_SCENE_DEFS.find((definition) => definition.key === bucket.key)?.icon || Shapes; + return ( + + ); + }) + )} +
+

仅本会话生效 · 缺失技能将加入技能库 · Agent 按任务调用

+
+ ); +} + +export function AgentChatComposer({ + prompt, + attachments = [], + disabled, + sending, + running, + placeholder, + theme, + onPromptChange, + onSubmit, + onAddFiles, + onRemoveAttachment, + left, + submitAccessory, + onStop, + stopping, + references = [], + slashSkills, + includeAssetLibrary, +}: { + prompt: string; + attachments?: CloudAgentChatAttachment[]; + disabled?: boolean; + sending?: boolean; + running?: boolean; + placeholder: string; + theme: (typeof canvasThemes)[keyof typeof canvasThemes]; + onPromptChange: (value: string) => void; + onSubmit: () => void; + onStop?: () => void | Promise; + stopping?: boolean; + onAddFiles?: (files: FileList | File[] | null) => void | Promise; + onRemoveAttachment?: (id: string) => void; + left?: ReactNode; + /** 发送按钮左侧的附属控件,例如上下文用量环。 */ + submitAccessory?: ReactNode; + /** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */ + references?: CanvasResourceReference[]; + /** 供「/」弹出的技能候选(可选) */ + slashSkills?: Skill[]; + /** 是否在「@」候选里包含素材库资源 */ + includeAssetLibrary?: boolean; +}) { + const fileInputRef = useRef(null); + const [slash, setSlash] = useState<{ start: number; query: string } | null>(null); + const [slashIndex, setSlashIndex] = useState(0); + const [previewAttachment, setPreviewAttachment] = useState(null); + const [promptHeight, setPromptHeight] = useState(MIN_AGENT_PROMPT_HEIGHT); + const promptResizeRef = useRef<{ startY: number; startHeight: number } | null>(null); + const manualPromptHeightRef = useRef(null); + const availableSlashSkills = slashSkills ?? []; + const slashCandidates = useMemo(() => { + const query = slash?.query.trim().toLocaleLowerCase() || ""; + if (!query) return availableSlashSkills; + return availableSlashSkills.filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(query)); + }, [availableSlashSkills, slash?.query]); + const attachmentReferences = useMemo(() => agentAttachmentReferences(attachments), [attachments]); + const skillReferences = useMemo(() => buildSkillMentionReferences(availableSlashSkills), [availableSlashSkills]); + const composerReferences = useMemo(() => [...references, ...skillReferences, ...attachmentReferences].filter((reference, index, all) => all.findIndex((item) => item.id === reference.id) === index), [attachmentReferences, references, skillReferences]); + const canStop = Boolean(running && onStop); + const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); + const reducedMotion = useReducedMotion(); + const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(slashCandidates.length - 1, 0)); + + const handlePromptContentSizeChange = useCallback((naturalHeight: number) => { + const nextHeight = clampAgentPromptHeight(naturalHeight); + // 用户开始拖动后,面板高度由用户掌控;超出部分交给内部滚动区, + // 避免输入新内容时又把手动缩小的面板强行撑开。 + setPromptHeight((currentHeight) => (manualPromptHeightRef.current === null ? nextHeight : currentHeight)); + }, []); + + useEffect(() => { + if (prompt.trim()) return; + manualPromptHeightRef.current = null; + setPromptHeight(MIN_AGENT_PROMPT_HEIGHT); + }, [prompt]); + + const startPromptResize = (event: ReactPointerEvent) => { + if (disabled) return; + event.preventDefault(); + manualPromptHeightRef.current = promptHeight; + promptResizeRef.current = { startY: event.clientY, startHeight: promptHeight }; + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const resizePrompt = (event: ReactPointerEvent) => { + const resize = promptResizeRef.current; + if (!resize) return; + setPromptHeight(clampAgentPromptHeight(resize.startHeight + resize.startY - event.clientY)); + }; + + const finishPromptResize = (event?: ReactPointerEvent) => { + promptResizeRef.current = null; + if (event?.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); + }; + + const resizePromptByKeyboard = (event: ReactKeyboardEvent) => { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + manualPromptHeightRef.current = promptHeight; + setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20))); + }; + + // 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。 + const handlePromptChange = (value: string) => { + onPromptChange(value); + const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value); + if (match && availableSlashSkills.length) { + const next = { start: match.index + match[1].length, query: match[2] }; + setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next)); + setSlashIndex(0); + } else if (slash) { + setSlash(null); + } + }; + + const applySlashSkill = (skill: Skill) => { + const token = `@[skill:${skill.skillId}] `; + // 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。 + const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token; + setSlash(null); + setSlashIndex(0); + onPromptChange(next); + }; + + // slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到) + const handleSlashKeyCapture = (event: ReactKeyboardEvent) => { + if (!slash || !slashCandidates.length) return; + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + setSlashIndex((index) => Math.min(index + 1, slashCandidates.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + event.stopPropagation(); + setSlashIndex((index) => Math.max(index - 1, 0)); + } else if (event.key === "Enter" || event.key === "Tab") { + event.preventDefault(); + event.stopPropagation(); + applySlashSkill(slashCandidates[activeSlashIndex]); + } else if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setSlash(null); + } + }; + + // 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片) + const handlePasteCapture = (event: ReactClipboardEvent) => { + if (!onAddFiles) return; + const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); + if (!images.length) return; + event.preventDefault(); + event.stopPropagation(); + void onAddFiles(images); + }; + + const insertAttachmentMention = (item: CloudAgentChatAttachment) => { + const token = `@[attachment:${item.id}] `; + if (prompt.includes(`@[attachment:${item.id}]`)) return; + onPromptChange(prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token); + }; + + return ( +
event.stopPropagation()}> +
+ {sending && !reducedMotion ? : null} + {attachments.length ? ( +
+ {attachments.map((item, index) => ( +
+ +
+ + {onRemoveAttachment ? ( + + ) : null} +
+
+ ))} +
+ ) : null} +
+ +
+ { + if (canSubmit) onSubmit(); + }} + onContentSizeChange={handlePromptContentSizeChange} + className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45" + containerClassName="min-h-[60px] h-full" + style={{ color: theme.node.text }} + placeholder={placeholder} + aria-label="Agent 输入" + /> +
+ {slash && slashCandidates.length ? ( +
event.preventDefault()} + > + {slashCandidates.map((skill, index) => ( + + ))} +
+ ) : null} +
+
+
+ {onAddFiles ? ( + <> + { + void onAddFiles(event.target.files); + event.target.value = ""; + }} + /> + +
+
+ {submitAccessory} + {canStop ? ( + void onStop?.()} + whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined} + whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined} + animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }} + transition={{ type: "spring", stiffness: 420, damping: 24 }} + className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" + style={{ background: theme.accent.danger, color: theme.accent.onPrimary }} + > + {stopping ? : } + + ) : null} + onSubmit()} + whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined} + whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined} + animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }} + transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }} + data-icon-only + className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" + style={{ + background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface, + color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted, + }} + > + + {sending ? : } + + +
+
+
+ {previewAttachment ? setPreviewAttachment(null)} /> : null} +
+ ); +} + +export function AgentImagePreview({ attachment, onClose }: { attachment: CloudAgentChatAttachment; onClose: () => void }) { + return ( +
+ {attachment.name} event.stopPropagation()} /> + +
+ ); +} diff --git a/web/src/components/canvas/canvas-cloud-agent-events.ts b/web/src/components/canvas/canvas-cloud-agent-events.ts new file mode 100644 index 000000000..08a6e1e83 --- /dev/null +++ b/web/src/components/canvas/canvas-cloud-agent-events.ts @@ -0,0 +1,348 @@ +// 画布 Agent 事件流的归约:把后端推送的 AgentEvent 逐条合并进面板的消息列表。 +// +// 事件可能重复(断线重连会重放最近一段)也可能乱序到达,因此所有写入都按事件 ID +// 或工具调用 ID 去重合并(appendUniqueMessage / upsertTextMessage / upsertMediaToolTrace), +// 而不是简单追加。这里只处理数据,不涉及渲染。 + +import type { AgentEvent, AgentRun } from "@/services/api/agent"; +import type { Dispatch, SetStateAction } from "react"; +import type { CloudAgentChatMessage, CloudAgentPlanItem } from "./canvas-cloud-agent-chat-ui"; +import { AGENT_APPROVAL_SUPERSEDED_BY_NODE } from "@/lib/canvas/agent-media-approval"; +import { agentToolRetry, mergeAgentToolRetry } from "@/lib/canvas/agent-tool-retry"; +import { agentErrorPresentation } from "@/lib/canvas/agent-error-presentation"; + +/** 待用户处理的 Agent 审批(面板状态与事件归约共用)。 */ +export type ApprovalState = { approvalId: string; detail: Record; reason: string }; + +export function positiveNumber(value: string) { + const number = Number(value); + return Number.isFinite(number) && number > 0 ? number : undefined; +} + +export function applyAgentEvent( + event: AgentEvent, + setMessages: Dispatch>, + setRun: Dispatch>, + setApproval: Dispatch>, + setPrompt?: Dispatch>, +) { + const payload = event.payload || {}; + const text = String(payload.text || payload.summary || payload.message || ""); + if (event.type === "run_status") { + const snapshotApproval = payload.approval && typeof payload.approval === "object" ? (payload.approval as AgentRun["approval"]) : undefined; + const nextStatus = String(payload.status || "") as AgentRun["status"]; + const terminal = ["completed", "failed", "cancelled", "rejected"].includes(nextStatus); + setRun((current) => + current + ? { + ...current, + status: nextStatus || current.status, + updatedAt: event.createdAt, + revision: Number(payload.revision || 0), + cleanupPending: Boolean(payload.cleanupPending), + failureMessage: String(payload.failureMessage || ""), + skills: payload.skills as AgentRun["skills"], + spentCredits: Number(payload.spentCredits || 0), + step: Number(payload.step || 0), + approval: snapshotApproval, + } + : current, + ); + if (terminal) { + setMessages((current) => current.map((message) => (message.id === `plan-${event.runId}` && message.planItems?.length ? { ...message, planTerminal: true, streaming: false } : message))); + } + if (terminal && nextStatus === "failed" && payload.failureMessage) { + setMessages((current) => appendAgentError(current, `terminal-${event.runId}`, String(payload.failureMessage), "Agent 执行失败", { severity: "error" })); + } + if (snapshotApproval && !snapshotApproval.decision && snapshotApproval.approvalId) { + setApproval((current) => ({ approvalId: snapshotApproval.approvalId, detail: snapshotApproval, reason: current?.approvalId === snapshotApproval.approvalId ? current.reason : snapshotApproval.reason || "" })); + } else { + setApproval(null); + } + return; + } + if (event.type === "approval_decided") { + setApproval(null); + if (payload.decision === AGENT_APPROVAL_SUPERSEDED_BY_NODE) { + setMessages((current) => + appendUniqueMessage(current, { + id: event.eventId, + role: "system", + text: text || "你已在画布节点上直接提交了生成,本次审批已自动关闭;Agent 不会重复提交或扣费。", + }), + ); + return; + } + if (payload.decision === "reject") { + setMessages((current) => + appendUniqueMessage(current, { + id: event.eventId, + role: "system", + text: text || "已拒绝本次操作,未写入画布。你可以告诉 Agent 修改方向后重新申请。", + }), + ); + } + return; + } + if (event.type === "progress_summary") { + setMessages((current) => appendUniqueMessage(current, { id: event.eventId, role: "system", text: text || "Agent 正在整理执行计划" })); + return; + } + if (event.type === "assistant_delta") { + setMessages((current) => upsertTextMessage(current, String(payload.messageId || "assistant"), text, true)); + return; + } + if (event.type === "reasoning_delta" || event.type === "reasoning_message") { + const id = String(payload.messageId || `${event.runId}:reasoning`); + setMessages((current) => upsertTextMessage(current, id, text, event.type === "reasoning_delta").map((item) => (item.id === id ? { ...item, reasoning: true } : item))); + return; + } + if (event.type === "plan_updated" && Array.isArray(payload.items)) { + const id = `plan-${event.runId}`; + const planItems = payload.items as CloudAgentPlanItem[]; + setMessages((current) => { + const index = current.findIndex((entry) => entry.id === id); + const message: CloudAgentChatMessage = { id, role: "tool", text: "", planItems }; + if (index < 0) return [...current, message]; + const next = [...current]; + next[index] = message; + return next; + }); + return; + } + if (event.type === "user_interjection") { + if (!text) return; + setMessages((current) => appendUniqueMessage(current, { id: String(payload.messageId || event.eventId), role: "user", text, interjection: "sent" })); + return; + } + if (event.type === "user_interjection_dropped") { + const messageId = String(payload.messageId || event.eventId); + const reason = String(payload.reason || "本轮已结束"); + setMessages((current) => { + const marked = current.map((item) => (item.id === messageId ? { ...item, interjection: "undelivered" as const } : item)); + return appendUniqueMessage(marked, { id: `interjection-dropped-${messageId}`, role: "system", text: `${reason},这条插话没有送到模型。需要的话重新发一次,它会作为新一轮。` }); + }); + setPrompt?.((current) => (current.trim() ? current : text)); + return; + } + if (event.type === "user_question") { + const options = Array.isArray(payload.options) + ? (payload.options as Array<{ label?: unknown; detail?: unknown }>).map((option) => ({ label: String(option?.label || "").trim(), detail: option?.detail === undefined ? undefined : String(option.detail) })).filter((option) => option.label) + : []; + const fields = Array.isArray(payload.fields) + ? (payload.fields as Array>) + .map((field) => ({ + id: String(field.id || "").trim(), + title: String(field.title || field.label || "").trim(), + type: ["single_select", "segmented", "text", "textarea", "model_picker"].includes(String(field.type)) ? (String(field.type) as "single_select" | "segmented" | "text" | "textarea" | "model_picker") : "single_select", + options: Array.isArray(field.options) + ? (field.options as Array>) + .map((option) => ({ + id: option.id === undefined ? undefined : String(option.id), + label: String(option.label || "").trim(), + detail: option.detail === undefined ? undefined : String(option.detail), + recommended: option.recommended === true, + })) + .filter((option) => option.label) + : undefined, + defaultValue: field.defaultValue === undefined ? undefined : String(field.defaultValue), + required: field.required === true, + allowCustom: field.allowCustom === true, + placeholder: field.placeholder === undefined ? undefined : String(field.placeholder), + })) + .filter((field) => field.id && field.title) + : []; + const question = String(payload.question || "").trim(); + const isForm = fields.length > 0 || payload.kind === "form"; + if (!question || (!isForm && options.length < 2)) return; + const id = `question-${event.runId}:${event.seq ?? event.eventId}`; + setMessages((current) => + appendUniqueMessage(current, { + id, + role: "assistant", + text: "", + question: { + question, + options, + fields: isForm ? fields : undefined, + kind: isForm ? "form" : "choice", + questionId: payload.questionId === undefined ? id : String(payload.questionId), + allowFreeform: payload.allowFreeform !== false, + round: Number.isFinite(Number(payload.round)) ? Number(payload.round) : undefined, + maxRounds: Number.isFinite(Number(payload.maxRounds)) ? Number(payload.maxRounds) : undefined, + }, + }), + ); + return; + } + if (event.type === "assistant_message") { + setMessages((current) => upsertTextMessage(current, String(payload.messageId || event.eventId), text, false)); + return; + } + if (event.type === "assistant_snapshot") { + setMessages((current) => upsertTextMessage(current, String(payload.messageId || event.eventId), text, false)); + return; + } + if (event.type === "approval_requested") { + const approvalId = String(payload.approvalId || ""); + setApproval((current) => ({ approvalId, detail: payload, reason: current?.approvalId === approvalId ? current.reason : "" })); + return; + } + if (event.type === "canvas_updated" && Array.isArray(payload.actions)) { + if (payload.operation === "generate_media_submit" || payload.operation === "generate_media_complete") return; + const { canvasPatch: _patch, ...detail } = payload; + const id = payload.callId ? `canvas-${event.runId}-${payload.callId}` : event.eventId; + setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: "画布操作已完成", detail: { ...detail, eventType: event.type } })); + return; + } + if (event.type.startsWith("tool_") && agentToolRetry(payload)) { + const message: CloudAgentChatMessage = { id: event.eventId, role: "tool", title: String(payload.toolName || "工具执行"), text, detail: { ...payload, eventType: event.type } }; + setMessages((current) => mergeAgentToolRetry(current, message)); + if (event.type === "tool_failed") return; + } + if (event.type === "tool_completed" && payload.toolName === "director_preview") { + const result = payload.result && typeof payload.result === "object" ? (payload.result as Record) : {}; + window.dispatchEvent( + new CustomEvent("director:preview-requested", { + detail: { + sceneId: String(result.sceneId || ""), + shotId: String(result.shotId || ""), + duration: Number(result.duration || 0), + fps: Number(result.fps || 0), + previewRequestId: String(result.previewRequestId || payload.callId || ""), + }, + }), + ); + setMessages((current) => + appendUniqueMessage(current, { + id: event.eventId, + role: "tool", + title: "director_preview", + text: text || "已请求导演台生成白模预演", + detail: { ...payload, eventType: event.type }, + }), + ); + return; + } + if (event.type === "tool_completed" && payload.toolName === "canvas_apply_ops" && payload.callId) { + const id = `canvas-${event.runId}-${payload.callId}`; + setMessages((current) => appendUniqueMessage(current, { id, role: "tool", title: "canvas_apply_ops", text: text || "画布操作已完成", detail: { ...payload, eventType: event.type } })); + return; + } + if (event.type === "generation_task_created") { + const message: CloudAgentChatMessage = { id: event.eventId, role: "tool", title: "generate_media", text: text || event.type, detail: { ...payload, eventType: event.type } }; + setMessages((current) => upsertMediaToolTrace(current, message)); + return; + } + if (event.type.startsWith("tool_")) { + const message: CloudAgentChatMessage = { id: event.eventId, role: "tool", title: String(payload.toolName || payload.title || "工具执行"), text: text || event.type, detail: { ...payload, eventType: event.type } }; + if (payload.toolName === "generate_media") { + setMessages((current) => upsertMediaToolTrace(current, message)); + } else { + setMessages((current) => appendUniqueMessage(current, message)); + } + return; + } + if (event.type === "run_failed" || event.type === "error") setMessages((current) => appendAgentError(current, event.eventId, text || "Agent 执行失败")); +} + +export function toolDetailRecord(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; +} + +export function toolDetailNodeIds(detail: unknown): Set { + const payload = toolDetailRecord(detail); + const ids = new Set(); + for (const value of [payload.nodeId, toolDetailRecord(payload.result).nodeId]) { + if (typeof value === "string" && value) ids.add(value); + } + if (Array.isArray(payload.actions)) { + for (const action of payload.actions) { + const nodeId = toolDetailRecord(action).nodeId; + if (typeof nodeId === "string" && nodeId) ids.add(nodeId); + } + } + if (typeof payload.arguments === "string") { + try { + const args = toolDetailRecord(JSON.parse(payload.arguments)); + if (Array.isArray(args.ops)) { + for (const op of args.ops) { + const nodeId = toolDetailRecord(op).id; + if (typeof nodeId === "string" && nodeId) ids.add(nodeId); + } + } + } catch { + // Tool arguments are diagnostic data; a malformed value must not break the event feed. + } + } + return ids; +} + +export function toolDetailTaskIds(detail: unknown): Set { + const payload = toolDetailRecord(detail); + const ids = new Set(); + for (const value of [payload.taskId, toolDetailRecord(payload.result).taskId]) { + if (typeof value === "string" && value) ids.add(value); + } + return ids; +} + +export function mergeToolDetails(previous: unknown, next: unknown): Record { + const previousDetail = toolDetailRecord(previous); + const nextDetail = toolDetailRecord(next); + return { + ...previousDetail, + ...nextDetail, + actions: Array.isArray(nextDetail.actions) ? nextDetail.actions : previousDetail.actions, + arguments: nextDetail.arguments || previousDetail.arguments, + }; +} + +export function upsertMediaToolTrace(current: CloudAgentChatMessage[], message: CloudAgentChatMessage): CloudAgentChatMessage[] { + const nextNodeIds = toolDetailNodeIds(message.detail); + const nextTaskIds = toolDetailTaskIds(message.detail); + const index = current.findIndex((item) => { + if (item.role !== "tool") return false; + const itemToolName = item.title || ""; + if (itemToolName !== "canvas_apply_ops" && itemToolName !== "generate_media") return false; + const itemNodeIds = toolDetailNodeIds(item.detail); + const itemTaskIds = toolDetailTaskIds(item.detail); + return [...nextNodeIds].some((id) => itemNodeIds.has(id)) || [...nextTaskIds].some((id) => itemTaskIds.has(id)); + }); + if (index < 0) return appendUniqueMessage(current, message); + const next = [...current]; + const previous = next[index]; + next[index] = { + ...previous, + ...message, + id: previous.id, + detail: mergeToolDetails(previous.detail, message.detail), + }; + return next; +} + +export function appendUniqueMessage(current: CloudAgentChatMessage[], message: CloudAgentChatMessage) { + return current.some((item) => item.id === message.id) ? current : [...current, message]; +} + +export function upsertTextMessage(current: CloudAgentChatMessage[], id: string, text: string, append: boolean): CloudAgentChatMessage[] { + const index = current.findIndex((item) => item.id === id); + if (index < 0) return [...current, { id, role: "assistant" as const, text, streaming: append }]; + if (!append && current[index].text === text && !current[index].streaming) return current; + const next = [...current]; + next[index] = { ...next[index], text: append ? `${next[index].text}${text}` : text, streaming: append }; + return next; +} + +export function appendAgentError(current: CloudAgentChatMessage[], id: string, cause: unknown, fallback?: string, options?: { severity?: CloudAgentChatMessage["errorSeverity"] }) { + const message = agentErrorPresentation(cause, fallback); + const last = current.at(-1); + if (last?.role === "error" && last.title === message.title && last.text === message.text) return current; + return appendUniqueMessage(current, { id, role: "error", ...message, ...(options?.severity ? { errorSeverity: options.severity } : {}) }); +} + +export function isNotFoundError(cause: unknown) { + if (!cause || typeof cause !== "object") return false; + const status = "status" in cause ? (cause as { status?: unknown }).status : undefined; + return status === 404 || (cause instanceof Error && /\(404\)/u.test(cause.message)); +} diff --git a/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx new file mode 100644 index 000000000..9ad39a11d --- /dev/null +++ b/web/src/components/canvas/canvas-cloud-agent-panel-parts.tsx @@ -0,0 +1,685 @@ +// 画布 Agent 面板的展示组件:悬浮入口、标题栏、上下文用量环、历史会话、对话区、 +// 输入区控件与审批卡片。 +// +// 这些组件只接收 props、不直接访问 Agent API;状态与副作用集中在 CanvasCloudAgentPanel。 + +import type { CanvasTheme } from "@/lib/canvas-theme"; +import { useAppearanceStore } from "@/stores/use-appearance-store"; +import { agentCopy, DEFAULT_CANVAS_APPEARANCE } from "@/lib/canvas/agent-appearance"; +import { type CSSProperties, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useAgentLauncherPosition } from "./use-agent-launcher-position"; +import { motion } from "motion/react"; +import { cn } from "@/lib/utils"; +import { Live2DAvatar } from "./live2d-avatar"; +import { live2DModelURL } from "@/services/api/appearance"; +import { FluidOrb } from "@/components/ui/fluid-orb"; +import { ArrowLeft, Check, CircleDot, Clock3, Download, History, LoaderCircle, MessageSquarePlus, RotateCcw, Settings2, ShieldCheck, Sparkles, Trash2, X } from "lucide-react"; +import { Button, Dropdown, Input, Popover } from "antd"; +import type { AgentContextPhase, AgentContextUsageView } from "@/lib/canvas/agent-context-usage"; +import type { CloudAgentConversation } from "@/services/cloud-agent-conversations"; +import { AgentChatMessage, AgentOperationFeed, AgentReasoningFeed, AgentWorkingMessage, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui"; +import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; +import type { CanvasNodeData } from "@/types/canvas"; +import type { AgentMediaSettings, AgentPermissionMode } from "@/services/api/agent"; +import { buildAgentFeedSegments } from "@/lib/canvas/agent-operation-feed"; +import { AgentWelcome } from "./canvas-agent-welcome"; +import { useEffectiveConfig } from "@/stores/use-config-store"; +import { agentPermissionLabel, agentPermissionMenuItems, agentPermissionVisual } from "./canvas-cloud-agent-settings"; +import { ModelPicker } from "@/components/model-picker"; +import { agentImageApproval } from "@/lib/canvas/agent-media-approval"; +import { agentApprovalPresentation } from "@/lib/canvas/agent-approval-presentation"; +import { CanvasAgentImageApprovalSettings } from "./canvas-agent-image-approval-settings"; +import { markdownPlainText } from "@/lib/markdown-plain-text"; +import type { ApprovalState } from "./canvas-cloud-agent-events"; + +export function AgentLauncher({ theme, statusColor, approvalPending, reducedMotion, onOpen }: { theme: CanvasTheme; statusColor: string; approvalPending: boolean; reducedMotion: boolean; onOpen: () => void }) { + const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; + const live = appearance.avatarType === "live2d" && Boolean(appearance.live2dResourceId && appearance.live2dEntry); + const [viewport, setViewport] = useState(() => ({ width: window.innerWidth, height: window.innerHeight })); + useEffect(() => { + const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight }); + window.addEventListener("resize", resize); + return () => window.removeEventListener("resize", resize); + }, []); + const height = live ? Math.max(40, Math.min(appearance.avatarHeight, viewport.height - 60, (viewport.width - 40) / 0.75)) : 76; + const width = live ? Math.round(height * 0.75) : 76; + const { position, dragging, handlers } = useAgentLauncherPosition(onOpen, width, height); + return ( + + {live ? ( + } /> + ) : ( + + )} + {appearance.launcherLabel ? {appearance.launcherLabel} : null} + + {approvalPending ? 待审批 : null} + + ); +} + +export function AgentHeader({ + theme, + hasMessages, + statusLabel, + statusColor, + nodeCount, + onNew, + onHistory, + onSettings, + onResetLayout, + onCollapse, + onExport, + exporting, +}: { + theme: CanvasTheme; + hasMessages: boolean; + statusLabel: string; + statusColor: string; + nodeCount: number; + onNew: () => void; + onHistory: () => void; + onSettings: () => void; + onResetLayout: () => void; + onCollapse: () => void; + onExport: () => void; + exporting: boolean; +}) { + const appearance = useAppearanceStore((state) => state.appearance.canvas) || DEFAULT_CANVAS_APPEARANCE; + return ( +
+
+
+ + {agentCopy(appearance.panelTitle, appearance.agentName)} + {hasMessages ? "" : " · 新对话"} + + + + {statusLabel} + +
+
+ {appearance.agentName} · 当前画布 {nodeCount} 个节点 +
+
+
+
+
+ ); +} + +export const CONTEXT_PHASE_LABEL: Record = { + idle: "尚未测量", + unknown: "窗口未知", + ok: "上下文充足", + watch: "接近压缩", + compress: "即将压缩", + compacting: "正在压缩", + stale: "压缩后待刷新", +}; + +export function formatContextCount(tokens: number | undefined) { + if (tokens === undefined) return "—"; + if (tokens >= 1_000_000) return `${Math.round(tokens / 100_000) / 10}M`; + if (tokens >= 1_000) return `${Math.round(tokens / 100) / 10}K`; + return Math.round(tokens).toLocaleString("zh-CN"); +} + +export function formatContextBytes(bytes: number | undefined) { + if (bytes === undefined) return "—"; + if (bytes >= 1_000_000) return `${Math.round(bytes / 100_000) / 10} MB`; + if (bytes >= 1_000) return `${Math.round(bytes / 100) / 10} KB`; + return `${Math.round(bytes).toLocaleString("zh-CN")} 字节`; +} + +export function AgentContextRing({ view }: { view: AgentContextUsageView }) { + const [open, setOpen] = useState(false); + const marker = view.compactRatio && view.compactRatio > 0 && view.compactRatio < 1 ? view.compactRatio : undefined; + const percent = view.ratio === undefined ? view.label : `${Math.round(view.ratio * 100)}%`; + const meterLabel = view.ratio === undefined ? "—" : percent; + const used = formatContextCount(view.inputTokens); + const budget = formatContextCount(view.usableTokens); + const remaining = formatContextCount(view.remainingTokens); + const protocolBytes = formatContextBytes(view.protocolBytes); + const usedRatio = view.ratio === undefined ? 0 : Math.max(0, Math.min(1, view.ratio)); + const phaseLabel = CONTEXT_PHASE_LABEL[view.phase]; + const sourceLabel = view.tokenSource === "provider" ? "模型实测校准" : view.estimate ? "本地估算" : "未测量"; + const usageHeading = view.ratio !== undefined ? `上下文已用 ${percent}` : view.phase === "idle" ? "上下文用量" : view.phase === "unknown" ? "上下文窗口未知" : `上下文${view.label}`; + + return ( + trigger.closest(".canvas-agent-panel") ?? document.body} + content={ +
+ 下一次请求 · 上下文窗口占用 +
+ {usageHeading} + {view.phase !== "ok" ? {phaseLabel} : null} +
+
+ {view.remainingTokens !== undefined && view.usableTokens !== undefined ? ( + <> + {used} + / {budget} Token + 剩余 {remaining} + + ) : ( + <> + {used} + Token + + )} +
+
+ 上下文窗口占用 + {percent} +
+
+ + {marker ?